Skip to main content

appcore_args/
raw.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: raw.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Raw argument ingestion with count, UTF-8 and byte budgets checked before retention.
12
13use crate::{CliError, CliErrorKind};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct ArgLimits {
17    pub max_words: usize,
18    pub max_word_bytes: usize,
19    pub max_total_bytes: usize,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct RawArgs {
24    words: Vec<String>,
25    limits: ArgLimits,
26}
27
28impl Default for ArgLimits {
29    fn default() -> Self {
30        Self {
31            max_words: 1024,
32            max_word_bytes: 64 * 1024,
33            max_total_bytes: 1024 * 1024,
34        }
35    }
36}
37
38impl RawArgs {
39    /// Validates each environment argument before retaining it; stops at the
40    /// first invalid word without collecting the remaining arguments.
41    pub fn from_env() -> Result<Self, CliError> {
42        Self::from_os_words(std::env::args_os().skip(1), ArgLimits::default())
43    }
44
45    fn from_os_words(
46        args: impl IntoIterator<Item = std::ffi::OsString>,
47        limits: ArgLimits,
48    ) -> Result<Self, CliError> {
49        let mut words = Vec::new();
50        let mut total_bytes = 0usize;
51        for (index, word) in args.into_iter().enumerate() {
52            let word = word.into_string().map_err(|_| {
53                CliError::new(
54                    CliErrorKind::InvalidInput,
55                    format!("argument {} is not valid UTF-8", index + 1),
56                )
57            })?;
58            validate_raw_word(&word, words.len(), total_bytes, limits)?;
59            total_bytes = total_bytes.saturating_add(word.len());
60            words.push(word);
61        }
62        Ok(Self { words, limits })
63    }
64
65    pub fn parse<I>(args: I) -> Result<Self, CliError>
66    where
67        I: IntoIterator,
68        I::Item: Into<String>,
69    {
70        Self::parse_with_limits(args, ArgLimits::default())
71    }
72
73    pub fn parse_with_limits<I>(args: I, limits: ArgLimits) -> Result<Self, CliError>
74    where
75        I: IntoIterator,
76        I::Item: Into<String>,
77    {
78        let mut words = Vec::new();
79        let mut total_bytes = 0usize;
80        for arg in args {
81            let value = arg.into();
82            validate_raw_word(&value, words.len(), total_bytes, limits)?;
83            total_bytes = total_bytes.saturating_add(value.len());
84            words.push(value);
85        }
86        Ok(Self { words, limits })
87    }
88
89    pub(crate) fn limits(&self) -> ArgLimits {
90        self.limits
91    }
92
93    pub fn words(&self) -> &[String] {
94        &self.words
95    }
96}
97
98fn validate_raw_word(
99    value: &str,
100    count: usize,
101    total: usize,
102    limits: ArgLimits,
103) -> Result<(), CliError> {
104    if count >= limits.max_words {
105        return Err(CliError::new(
106            CliErrorKind::InvalidInput,
107            format!("argument count exceeds limit of {}", limits.max_words),
108        ));
109    }
110    if value.len() > limits.max_word_bytes {
111        return Err(CliError::new(
112            CliErrorKind::InvalidInput,
113            format!(
114                "argument {} exceeds byte limit of {}",
115                count + 1,
116                limits.max_word_bytes
117            ),
118        ));
119    }
120    if total.saturating_add(value.len()) > limits.max_total_bytes {
121        return Err(CliError::new(
122            CliErrorKind::InvalidInput,
123            format!(
124                "total argument bytes exceed limit of {}",
125                limits.max_total_bytes
126            ),
127        ));
128    }
129    if value.contains('\0') {
130        return Err(CliError::new(
131            CliErrorKind::InvalidInput,
132            format!("argument {} contains a NUL byte", count + 1),
133        ));
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::ffi::OsString;
142
143    #[test]
144    fn environment_ingestion_stops_consuming_at_first_limit_failure() {
145        let mut consumed = 0;
146        let args = ["first", "second", "never-read"].into_iter().map(|word| {
147            consumed += 1;
148            OsString::from(word)
149        });
150        let limits = ArgLimits {
151            max_words: 1,
152            ..ArgLimits::default()
153        };
154        assert!(RawArgs::from_os_words(args, limits).is_err());
155        assert_eq!(consumed, 2);
156    }
157
158    #[test]
159    fn environment_ingestion_enforces_exact_aggregate_utf8_bytes() {
160        let limits = ArgLimits {
161            max_words: 2,
162            max_word_bytes: 2,
163            max_total_bytes: 3,
164        };
165        let accepted =
166            RawArgs::from_os_words([OsString::from("é"), OsString::from("a")], limits).unwrap();
167        assert_eq!(accepted.words(), &["é", "a"]);
168        assert!(
169            RawArgs::from_os_words([OsString::from("é"), OsString::from("ab")], limits).is_err()
170        );
171    }
172
173    #[cfg(unix)]
174    #[test]
175    fn invalid_environment_encoding_keeps_the_argument_index() {
176        use std::os::unix::ffi::OsStringExt;
177        let error = RawArgs::from_os_words(
178            [OsString::from("valid"), OsString::from_vec(vec![0xff])],
179            ArgLimits::default(),
180        )
181        .unwrap_err();
182        assert_eq!(error.to_string(), "argument 2 is not valid UTF-8");
183    }
184}