Skip to main content

nix_compat/
structured_attrs.rs

1//! Contains the code rendering the shell script that's used for structured attrs.
2//!
3
4/// Checks whether `s` is a valid shell variable name, matching `[A-Za-z_][A-Za-z0-9_]*`.
5fn is_valid_sh_var_name(s: &str) -> bool {
6    let mut bytes = s.bytes();
7    let Some(b'A'..=b'Z' | b'a'..=b'z' | b'_') = bytes.next() else {
8        return false;
9    };
10    bytes.all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_'))
11}
12
13/// Writes an escaped version of the passed string to the writer.
14fn write_shell_escaped_single_quoted<W>(f: &mut W, s: &str) -> std::fmt::Result
15where
16    W: std::fmt::Write,
17{
18    write!(f, "'")?;
19    for c in s.chars() {
20        if c == '\'' {
21            write!(f, "'\\''")?;
22        } else {
23            write!(f, "{c}")?;
24        }
25    }
26    write!(f, "'")?;
27    Ok(())
28}
29
30/// determine if the value is "good to print".
31/// We essentially want to reject floats which are not just integers.
32fn is_good_simple_value(v: &serde_json::Value) -> bool {
33    match v {
34        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => true,
35        serde_json::Value::Number(number) => {
36            if number.as_i64().is_some() || number.as_u64().is_some() {
37                true
38            } else if let Some(n) = number.as_f64() {
39                n.ceil() == n
40            } else if number.as_i128().is_some() {
41                true
42            } else {
43                number.as_u128().is_some()
44            }
45        }
46        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
47            unreachable!("Snix bug: called write_simple_type on complex type")
48        }
49    }
50}
51
52fn write_simple_type<W>(f: &mut W, v: serde_json::Value) -> std::fmt::Result
53where
54    W: std::fmt::Write,
55{
56    match v {
57        serde_json::Value::Null => write!(f, "''")?,
58        serde_json::Value::Bool(v) => {
59            if v {
60                write!(f, "1")?;
61            } else {
62                write!(f, "")?;
63            }
64        }
65        serde_json::Value::Number(number) => {
66            if let Some(n) = number.as_i64() {
67                write!(f, "{n}")?;
68            } else if let Some(n) = number.as_u64() {
69                write!(f, "{n}")?;
70            } else if let Some(n) = number.as_f64() {
71                debug_assert!(n.ceil() == n, "bad number value");
72                write!(f, "{}", n.ceil() as i64)?;
73            } else if let Some(n) = number.as_i128() {
74                write!(f, "{n}")?;
75            } else if let Some(n) = number.as_u128() {
76                write!(f, "{n}")?;
77            } else {
78                panic!("unable to represent number");
79            }
80        }
81        serde_json::Value::String(s) => {
82            write_shell_escaped_single_quoted(f, &s)?;
83        }
84        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
85            unreachable!("Snix bug: called write_simple_type on complex type")
86        }
87    }
88
89    Ok(())
90}
91
92/// for a given json map, write the file contents of the to-be-sourced bash script.
93/// Cf. writeStructuredAttrsShell in Cppnix.
94pub fn write_attrs_sh_file<W>(
95    f: &mut W,
96    map: serde_json::Map<String, serde_json::Value>,
97) -> std::fmt::Result
98where
99    W: std::fmt::Write,
100{
101    for (k, v) in map {
102        // keys with spaces, backslashes (and potentially everything else making
103        // that key an invalid identifier) are silently skipped from the bash
104        // file (but present in the ATerm!)
105        if !is_valid_sh_var_name(k.as_str()) {
106            continue;
107        }
108        match v {
109            serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::String(_) => {
110                write!(f, "declare {k}=")?;
111                write_simple_type(f, v)?;
112                writeln!(f)?;
113            }
114            serde_json::Value::Number(_) => {
115                // Nix skips over bad values (floats which can't be represented as integers)
116                if !is_good_simple_value(&v) {
117                    continue;
118                }
119                write!(f, "declare {k}=")?;
120                write_simple_type(f, v)?;
121                writeln!(f)?;
122            }
123            serde_json::Value::Array(values) => {
124                // If the array contains any invalid element, the array is skipped entirely.
125                if values.iter().any(|v| !is_good_simple_value(v)) {
126                    continue;
127                }
128                write!(f, "declare -a {k}=(")?;
129                for val in values {
130                    write_simple_type(f, val)?;
131                    write!(f, " ")?;
132                }
133                writeln!(f, ")")?;
134            }
135            serde_json::Value::Object(map) => {
136                // If the array contains any invalid element, or another
137                // non-simple type, the object is skipped entirely.
138                if map.values().any(|v| {
139                    matches!(
140                        v,
141                        serde_json::Value::Array(_) | serde_json::Value::Object(_)
142                    ) || !is_good_simple_value(v)
143                }) {
144                    continue;
145                }
146
147                write!(f, "declare -A {k}=(")?;
148                for (k, v) in map {
149                    // Nix shell-escapes the key (parsed-derivations.cc,
150                    // writeStructuredAttrsShell); unlike the outer var name,
151                    // inner keys are not filtered and may contain quotes.
152                    write!(f, "[")?;
153                    write_shell_escaped_single_quoted(f, &k)?;
154                    write!(f, "]=")?;
155                    write_simple_type(f, v)?;
156                    write!(f, " ")?;
157                }
158                writeln!(f, ")")?;
159            }
160        }
161    }
162
163    Ok(())
164}
165
166#[cfg(test)]
167mod test {
168    use rstest::rstest;
169    use serde_json::json;
170
171    use super::write_attrs_sh_file;
172
173    #[rstest]
174    #[case::empty(json!({}), "")]
175    #[case::empty_key(json!({"": "value"}), "")]
176    #[case::null(json!({"k": null}), r#"declare k=''"#)]
177    #[case::string(json!({"k":"v"}), r#"declare k='v'"#)]
178    #[case::string_escaping(json!({"k":"v'w"}), r#"declare k='v'\''w'"#)]
179    #[case::bool_false(json!({"k":false}), r#"declare k="#)]
180    #[case::bool_true(json!({"k":true}), r#"declare k=1"#)]
181    #[case::number(json!({"k":1}), r#"declare k=1"#)]
182    #[case::number_float(json!({"k":1.0}), r#"declare k=1"#)]
183    #[case::number_float_invalid(json!({"k":1.1}), r#""#)]
184    #[case::array_of_strings(json!({"k": ["bar", "baz"]}), r#"declare -a k=('bar' 'baz' )"#)]
185    #[case::array_of_strings_and_bool(json!({"k": ["bar", true]}), r#"declare -a k=('bar' 1 )"#)]
186    #[case::array_of_strings_and_invalid_number(json!({"k": ["bar", 1.1]}), "")]
187    #[case::object_key_escaping(json!(
188        {"k": {"it's": "v"}}), r#"declare -A k=(['it'\''s']='v' )"#)]
189    #[case::object(json!(
190        {"k": {
191            "bar": true,
192            "b": 1.0,
193            "c": false,
194            "d": true,
195        }}), r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )"#)]
196    #[case::object_invalid_number(json!(
197        {"k": {
198            "bar": true,
199            "b": 1.1,
200        }}), "")]
201    #[case::object_too_complex(json!(
202        {"k": {
203            "bar": true,
204            "baz": [],
205        }}), r#""#)]
206    #[case::multiple(json!(
207        {
208            "k": {
209                "bar": true,
210                "b": 1.0,
211                "c": false,
212                "d": true,
213            },
214            "l": 42,
215            "m": false,
216            "n": 1.1,
217        }),
218        r#"declare -A k=(['b']=1 ['bar']=1 ['c']= ['d']=1 )
219declare l=42
220declare m="#)]
221    fn write_attrs(#[case] val: serde_json::Value, #[case] exp_output: &str) {
222        let mut out = String::new();
223        let map = val.as_object().expect("must be map").to_owned();
224        write_attrs_sh_file(&mut out, map).expect("must succeed");
225
226        if exp_output.is_empty() {
227            assert_eq!(exp_output, out, "expected output to match");
228        } else {
229            assert_eq!(
230                {
231                    let mut exp_output = String::from(exp_output);
232                    exp_output.push('\n');
233                    exp_output
234                },
235                out,
236                "expected output to match"
237            );
238        }
239    }
240}