Skip to main content

appcore_args/
native.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: native.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/05 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/05 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.2
9// =============================================================================
10
11//! Native argv ingestion and typed path/address results without lossy conversion.
12
13use crate::{ArgLimits, CliError, CliErrorKind, OptionSpec, ValueMode, ValueType};
14use std::ffi::{OsStr, OsString};
15use std::net::SocketAddr;
16use std::path::PathBuf;
17
18/// Additional native value validation, preserving the stable `ValueType` enum.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum ValueKind {
22    /// UTF-8 text validated by `ValueType`.
23    #[default]
24    Text,
25    /// Nonempty native path; no filesystem access or canonicalization.
26    Path,
27    /// Numeric IP address and port, including bracketed IPv6; no DNS lookup.
28    SocketAddr,
29}
30
31/// Bounded native arguments, excluding the executable name.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RawOsArgs {
34    words: Vec<OsString>,
35    limits: ArgLimits,
36}
37impl RawOsArgs {
38    /// Reads native argv incrementally using default budgets.
39    pub fn from_env() -> Result<Self, CliError> {
40        Self::parse(std::env::args_os().skip(1))
41    }
42    /// Preserves native bytes with default limits.
43    pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, CliError> {
44        Self::parse_with_limits(args, ArgLimits::default())
45    }
46    /// Checks each native word before retaining it, stopping on first failure.
47    pub fn parse_with_limits(
48        args: impl IntoIterator<Item = OsString>,
49        limits: ArgLimits,
50    ) -> Result<Self, CliError> {
51        let mut words = Vec::new();
52        let mut total = 0;
53        for word in args {
54            validate_word(&word, words.len(), total, limits)?;
55            total += word.as_encoded_bytes().len();
56            words.push(word);
57        }
58        Ok(Self { words, limits })
59    }
60    /// Native words in original order.
61    pub fn words(&self) -> &[OsString] {
62        &self.words
63    }
64    pub(crate) fn limits(&self) -> ArgLimits {
65        self.limits
66    }
67}
68
69/// Native parsed option; `Debug` hides values because environment may hold secrets.
70#[derive(Clone, PartialEq, Eq)]
71pub struct ParsedOsOption {
72    pub(crate) name: String,
73    pub(crate) value: Option<OsString>,
74}
75impl std::fmt::Debug for ParsedOsOption {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("ParsedOsOption")
78            .field("name", &self.name)
79            .field("value", &"[REDACTED]")
80            .finish()
81    }
82}
83impl ParsedOsOption {
84    /// Canonical option name without dashes.
85    pub fn name(&self) -> &str {
86        &self.name
87    }
88    /// Original native value, if present.
89    pub fn value(&self) -> Option<&OsStr> {
90        self.value.as_deref()
91    }
92}
93/// Parsed native values; option values are redacted from `Debug`.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ParsedOsCli {
96    pub(crate) command_path: Vec<String>,
97    pub(crate) options: Vec<ParsedOsOption>,
98    pub(crate) positionals: Vec<OsString>,
99    pub(crate) passthrough: Vec<OsString>,
100}
101impl ParsedOsCli {
102    /// Canonical command path.
103    pub fn command_path(&self) -> &[String] {
104        &self.command_path
105    }
106    /// Options including resolved environment/default values.
107    pub fn options(&self) -> &[ParsedOsOption] {
108        &self.options
109    }
110    /// Native positional arguments.
111    pub fn positionals(&self) -> &[OsString] {
112        &self.positionals
113    }
114    /// Uninterpreted native values following --.
115    pub fn passthrough(&self) -> &[OsString] {
116        &self.passthrough
117    }
118    /// Whether an option is present after source resolution.
119    pub fn has_flag(&self, name: &str) -> bool {
120        self.options.iter().any(|o| o.name == name)
121    }
122    /// Last resolved native value.
123    pub fn option_value(&self, name: &str) -> Option<&OsStr> {
124        self.options
125            .iter()
126            .rev()
127            .find(|o| o.name == name)
128            .and_then(ParsedOsOption::value)
129    }
130    /// All native values for a repeatable option.
131    pub fn option_values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a OsStr> + 'a {
132        self.options
133            .iter()
134            .filter(move |o| o.name == name)
135            .filter_map(ParsedOsOption::value)
136    }
137    /// Number of occurrences including a resolved fallback, if used.
138    pub fn option_occurrences(&self, name: &str) -> usize {
139        self.options.iter().filter(|o| o.name == name).count()
140    }
141    /// Returns a native path without lossy conversion.
142    pub fn option_path(&self, name: &str) -> Option<PathBuf> {
143        self.option_value(name).map(PathBuf::from)
144    }
145    /// Parses a numeric socket address without DNS.
146    pub fn option_socket_addr(&self, name: &str) -> Result<Option<SocketAddr>, CliError> {
147        self.option_value(name)
148            .map(|v| {
149                v.to_str().and_then(|s| s.parse().ok()).ok_or_else(|| {
150                    CliError::new(
151                        CliErrorKind::InvalidValue,
152                        format!("invalid socket address for --{name}"),
153                    )
154                })
155            })
156            .transpose()
157    }
158    pub(crate) fn into_text(self) -> Result<crate::ParsedCli, CliError> {
159        let convert = |v: OsString| {
160            v.into_string().map_err(|_| {
161                CliError::new(CliErrorKind::InvalidInput, "native value requires parse_os")
162            })
163        };
164        Ok(crate::ParsedCli {
165            command_path: self.command_path,
166            options: self
167                .options
168                .into_iter()
169                .map(|o| {
170                    Ok(crate::ParsedOption {
171                        name: o.name,
172                        value: o.value.map(&convert).transpose()?,
173                    })
174                })
175                .collect::<Result<_, CliError>>()?,
176            positionals: self
177                .positionals
178                .into_iter()
179                .map(&convert)
180                .collect::<Result<_, _>>()?,
181            passthrough: self
182                .passthrough
183                .into_iter()
184                .map(convert)
185                .collect::<Result<_, _>>()?,
186        })
187    }
188}
189
190pub(crate) fn validate_word(
191    value: &OsStr,
192    count: usize,
193    total: usize,
194    limits: ArgLimits,
195) -> Result<(), CliError> {
196    let bytes = value.as_encoded_bytes();
197    if count >= limits.max_words
198        || bytes.len() > limits.max_word_bytes
199        || total.saturating_add(bytes.len()) > limits.max_total_bytes
200        || bytes.contains(&0)
201    {
202        return Err(CliError::new(
203            CliErrorKind::InvalidInput,
204            "native argument exceeds count/byte budget or contains NUL",
205        ));
206    }
207    Ok(())
208}
209
210pub(crate) fn validate_native(
211    value: &OsStr,
212    kind: ValueKind,
213    ty: ValueType,
214    possible: &[String],
215    owner: &str,
216) -> Result<(), CliError> {
217    if kind == ValueKind::Path {
218        if value.is_empty() {
219            return Err(CliError::new(
220                CliErrorKind::InvalidValue,
221                format!("empty path for {owner}"),
222            ));
223        }
224        if possible.is_empty() {
225            return Ok(());
226        }
227    }
228    let text = value.to_str().ok_or_else(|| {
229        CliError::new(
230            CliErrorKind::InvalidValue,
231            format!("{owner} requires UTF-8 text"),
232        )
233    })?;
234    if kind == ValueKind::SocketAddr && text.parse::<SocketAddr>().is_err() {
235        return Err(CliError::new(
236            CliErrorKind::InvalidValue,
237            format!("{owner} requires a numeric socket address"),
238        ));
239    }
240    // Do not echo environment values or secrets in errors.
241    crate::value_parser::validate_value(text, ty, possible, owner)
242}
243
244pub(crate) fn split_attached(word: &OsStr, separator: u8) -> (OsString, Option<OsString>) {
245    let bytes = word.as_encoded_bytes();
246    match bytes.iter().position(|b| *b == separator) {
247        Some(i) => (
248            os_slice(word, 0, i),
249            Some(os_slice(word, i + 1, bytes.len())),
250        ),
251        None => (word.to_os_string(), None),
252    }
253}
254
255pub(crate) fn os_slice(word: &OsStr, start: usize, end: usize) -> OsString {
256    // Boundaries are immediately adjacent to ASCII option syntax, so valid UTF-8
257    // words can be sliced directly. Platform-native conversion preserves others.
258    #[cfg(unix)]
259    {
260        use std::os::unix::ffi::{OsStrExt, OsStringExt};
261        OsString::from_vec(word.as_bytes()[start..end].to_vec())
262    }
263    #[cfg(windows)]
264    {
265        use std::os::windows::ffi::{OsStrExt, OsStringExt};
266        let units = word.encode_wide().collect::<Vec<_>>();
267        let bytes = word.as_encoded_bytes();
268        if !bytes[..start].is_ascii() || (end != bytes.len() && !bytes[..end].is_ascii()) {
269            return word.to_os_string();
270        }
271        let length = if end == bytes.len() { units.len() } else { end };
272        OsString::from_wide(&units[start..length])
273    }
274    #[cfg(not(any(unix, windows)))]
275    {
276        OsString::from(&word.to_str().unwrap_or("")[start..end])
277    }
278}
279
280pub(crate) fn option_value(
281    words: &[OsString],
282    index: usize,
283    inline: Option<OsString>,
284    option: &OptionSpec,
285    short: bool,
286) -> Result<(Option<OsString>, usize), CliError> {
287    match option.value_mode() {
288        ValueMode::Forbidden if inline.is_some() && !short => Err(CliError::new(
289            CliErrorKind::InvalidValue,
290            "flag does not accept a value",
291        )),
292        ValueMode::Forbidden => Ok((None, index + 1)),
293        ValueMode::Required => match inline {
294            Some(value) => Ok((Some(value), index + 1)),
295            None => words
296                .get(index + 1)
297                .cloned()
298                .map(|v| (Some(v), index + 2))
299                .ok_or_else(|| {
300                    CliError::new(
301                        CliErrorKind::MissingValue,
302                        format!("--{} requires a value", option.long()),
303                    )
304                }),
305        },
306        ValueMode::Optional => {
307            if inline.is_some() || !option.accepts_detached_optional_value() {
308                return Ok((inline, index + 1));
309            }
310            match words.get(index + 1).filter(|v| {
311                validate_native(
312                    v,
313                    option.kind,
314                    option.value_type_kind(),
315                    option.possible_values(),
316                    option.long(),
317                )
318                .is_ok()
319            }) {
320                Some(v) => Ok((Some(v.clone()), index + 2)),
321                None => Ok((None, index + 1)),
322            }
323        }
324    }
325}