Skip to main content

vault_core/
php_config.rs

1//! A reader for PHP array literals, enough for application config files such as
2//! Nextcloud's `config/config.php` (Phase 38.1, ADR-0148).
3//!
4//! Nothing here executes anything. The file is *data written in PHP syntax*:
5//! `$CONFIG = array ( 'key' => 'value', ... );`, with the short `[ ... ]` form
6//! accepted too. What is understood is strings (single and double quoted),
7//! integers, floats, `true`/`false`/`null`, nested arrays, and the three comment
8//! styles. Anything else - a function call, a constant, a concatenation, a
9//! variable - cannot be known without running PHP, so its value becomes `null`
10//! and a warning naming the key and line is recorded instead of guessing.
11//!
12//! Every length the file can control is bounded: nesting depth and total node
13//! count, so a hostile file cannot make this recurse or allocate without limit.
14
15use serde_json::{Map, Value};
16
17const MAX_DEPTH: usize = 32;
18const MAX_NODES: usize = 100_000;
19
20/// The parsed array and anything that could not be read.
21#[derive(Debug, PartialEq)]
22pub struct Parsed {
23    pub value: Value,
24    pub warnings: Vec<String>,
25}
26
27/// Parse the array assigned to the first `=` in `src`.
28pub fn parse(src: &str) -> Result<Parsed, String> {
29    parse_inner(src, None)
30}
31
32/// Parse the array assigned to `$name` (for Nextcloud, `CONFIG`). A file that is a
33/// script rather than a config array - one that sets other variables first - is
34/// read from the right assignment instead of the first `=`.
35pub fn parse_var(src: &str, name: &str) -> Result<Parsed, String> {
36    parse_inner(src, Some(name))
37}
38
39fn parse_inner(src: &str, var: Option<&str>) -> Result<Parsed, String> {
40    let mut p = P {
41        s: src.as_bytes(),
42        i: 0,
43        nodes: 0,
44        warnings: Vec::new(),
45    };
46    p.skip_to_array(var)?;
47    let value = p.array(0)?;
48    Ok(Parsed {
49        value,
50        warnings: p.warnings,
51    })
52}
53
54struct P<'a> {
55    s: &'a [u8],
56    i: usize,
57    nodes: usize,
58    warnings: Vec<String>,
59}
60
61impl P<'_> {
62    fn line(&self) -> usize {
63        self.line_at(self.i)
64    }
65
66    /// Computed on demand only: counting newlines is O(n), and doing it per value
67    /// made a large file quadratic.
68    fn line_at(&self, at: usize) -> usize {
69        1 + self.s[..at.min(self.s.len())]
70            .iter()
71            .filter(|&&b| b == b'\n')
72            .count()
73    }
74
75    fn peek(&self) -> Option<u8> {
76        self.s.get(self.i).copied()
77    }
78
79    fn ws(&mut self) {
80        loop {
81            match self.peek() {
82                Some(b' ' | b'\t' | b'\r' | b'\n') => self.i += 1,
83                Some(b'#') => self.line_comment(),
84                Some(b'/') if self.s.get(self.i + 1) == Some(&b'/') => self.line_comment(),
85                Some(b'/') if self.s.get(self.i + 1) == Some(&b'*') => {
86                    self.i += 2;
87                    while self.i < self.s.len()
88                        && !(self.s[self.i] == b'*' && self.s.get(self.i + 1) == Some(&b'/'))
89                    {
90                        self.i += 1;
91                    }
92                    self.i = (self.i + 2).min(self.s.len());
93                }
94                _ => return,
95            }
96        }
97    }
98
99    fn line_comment(&mut self) {
100        while let Some(b) = self.peek() {
101            if b == b'\n' {
102                return;
103            }
104            self.i += 1;
105        }
106    }
107
108    /// Move to the opening of the array that follows the wanted `=`: the first one,
109    /// or the one assigned to `$var`.
110    fn skip_to_array(&mut self, var: Option<&str>) -> Result<(), String> {
111        while self.i < self.s.len() {
112            self.ws();
113            match self.peek() {
114                Some(b'$') if var.is_some() => {
115                    let want = var.unwrap_or("").as_bytes();
116                    let name_end = self.i + 1 + want.len();
117                    let named = self.s.get(self.i + 1..name_end) == Some(want)
118                        && !self
119                            .s
120                            .get(name_end)
121                            .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_');
122                    self.i += 1;
123                    if named {
124                        self.i = name_end;
125                        self.ws();
126                        if self.peek() == Some(b'=') && self.s.get(self.i + 1) != Some(&b'=') {
127                            self.i += 1;
128                            self.ws();
129                            return Ok(());
130                        }
131                    }
132                }
133                Some(b'=') if var.is_none() => {
134                    self.i += 1;
135                    self.ws();
136                    return Ok(());
137                }
138                Some(b'\'' | b'"') => {
139                    self.string()?;
140                }
141                Some(_) => self.i += 1,
142                None => break,
143            }
144        }
145        Err("No `$CONFIG = array(...)` assignment found".into())
146    }
147
148    fn keyword_ci(&mut self, kw: &str) -> bool {
149        let end = self.i + kw.len();
150        if end <= self.s.len() && self.s[self.i..end].eq_ignore_ascii_case(kw.as_bytes()) {
151            let after = self.s.get(end).copied();
152            if !after.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_') {
153                self.i = end;
154                return true;
155            }
156        }
157        false
158    }
159
160    fn array(&mut self, depth: usize) -> Result<Value, String> {
161        if depth > MAX_DEPTH {
162            return Err(format!(
163                "Arrays nest deeper than {MAX_DEPTH} (line {})",
164                self.line()
165            ));
166        }
167        self.ws();
168        let close = if self.keyword_ci("array") {
169            self.ws();
170            if self.peek() != Some(b'(') {
171                return Err(format!(
172                    "Expected `(` after `array` on line {}",
173                    self.line()
174                ));
175            }
176            self.i += 1;
177            b')'
178        } else if self.peek() == Some(b'[') {
179            self.i += 1;
180            b']'
181        } else {
182            return Err(format!("Expected an array on line {}", self.line()));
183        };
184
185        let mut items: Vec<(Option<Value>, Value)> = Vec::new();
186        loop {
187            self.ws();
188            match self.peek() {
189                None => return Err("The array is never closed".into()),
190                Some(b) if b == close => {
191                    self.i += 1;
192                    break;
193                }
194                Some(b',') => {
195                    self.i += 1;
196                    continue;
197                }
198                _ => {}
199            }
200            self.nodes += 1;
201            if self.nodes > MAX_NODES {
202                return Err("The file has too many entries to read".into());
203            }
204            let first = self.value(depth, close)?;
205            self.ws();
206            if self.s[self.i..].starts_with(b"=>") {
207                self.i += 2;
208                let v = self.value(depth, close)?;
209                items.push((Some(first), v));
210            } else {
211                items.push((None, first));
212            }
213        }
214
215        // A list when no key was written, or the keys are exactly 0..n.
216        let sequential = items.iter().enumerate().all(|(n, (k, _))| match k {
217            None => true,
218            Some(Value::Number(x)) => x.as_u64() == Some(n as u64),
219            _ => false,
220        });
221        if sequential {
222            return Ok(Value::Array(items.into_iter().map(|(_, v)| v).collect()));
223        }
224        let mut m = Map::new();
225        for (n, (k, v)) in items.into_iter().enumerate() {
226            let key = match k {
227                Some(Value::String(s)) => s,
228                Some(Value::Number(x)) => x.to_string(),
229                Some(_) | None => n.to_string(),
230            };
231            m.insert(key, v);
232        }
233        Ok(Value::Object(m))
234    }
235
236    fn value(&mut self, depth: usize, close: u8) -> Result<Value, String> {
237        self.ws();
238        let start = self.i;
239        let v = match self.peek() {
240            Some(b'\'' | b'"') => Value::String(self.string()?),
241            Some(b'[') => self.array(depth + 1)?,
242            Some(b) if b.is_ascii_digit() || b == b'-' || b == b'+' => self.number(),
243            _ if self.keyword_ci("array") => {
244                self.i -= "array".len();
245                self.array(depth + 1)?
246            }
247            _ if self.keyword_ci("true") => Value::Bool(true),
248            _ if self.keyword_ci("false") => Value::Bool(false),
249            _ if self.keyword_ci("null") => Value::Null,
250            _ => {
251                self.skip_expression(close);
252                self.warnings.push(format!(
253                    "line {}: an expression that needs PHP to evaluate was left out",
254                    self.line_at(start)
255                ));
256                return Ok(Value::Null);
257            }
258        };
259        // A string followed by `.` is a concatenation: its value is not what was read.
260        self.ws();
261        if self.peek() == Some(b'.') {
262            self.skip_expression(close);
263            self.warnings.push(format!(
264                "line {}: a concatenation was left out",
265                self.line_at(start)
266            ));
267            return Ok(Value::Null);
268        }
269        Ok(v)
270    }
271
272    /// Skip to the next `,`, `=>` or closing bracket at this nesting level.
273    fn skip_expression(&mut self, close: u8) {
274        let mut depth = 0usize;
275        while let Some(b) = self.peek() {
276            match b {
277                b'\'' | b'"' => {
278                    let _ = self.string();
279                    continue;
280                }
281                b'(' | b'[' => depth += 1,
282                b')' | b']' if depth > 0 => depth -= 1,
283                b',' if depth == 0 => return,
284                b'=' if depth == 0 && self.s.get(self.i + 1) == Some(&b'>') => return,
285                b if depth == 0 && b == close => return,
286                _ => {}
287            }
288            self.i += 1;
289        }
290    }
291
292    fn number(&mut self) -> Value {
293        let start = self.i;
294        if matches!(self.peek(), Some(b'-' | b'+')) {
295            self.i += 1;
296        }
297        while matches!(self.peek(), Some(b) if b.is_ascii_digit() || b == b'.' || b == b'e' || b == b'E' || b == b'_')
298        {
299            self.i += 1;
300        }
301        let text: String = String::from_utf8_lossy(&self.s[start..self.i]).replace('_', "");
302        if let Ok(n) = text.parse::<i64>() {
303            return Value::from(n);
304        }
305        text.parse::<f64>()
306            .ok()
307            .and_then(serde_json::Number::from_f64)
308            .map_or(Value::Null, Value::Number)
309    }
310
311    fn string(&mut self) -> Result<String, String> {
312        let quote = self.s[self.i];
313        let start = self.i;
314        self.i += 1;
315        let mut out: Vec<u8> = Vec::new();
316        loop {
317            let Some(b) = self.peek() else {
318                return Err(format!(
319                    "The string opened on line {} is never closed",
320                    self.line_at(start)
321                ));
322            };
323            self.i += 1;
324            if b == quote {
325                break;
326            }
327            if b != b'\\' {
328                out.push(b);
329                continue;
330            }
331            let Some(e) = self.peek() else {
332                return Err(format!(
333                    "The string opened on line {} is never closed",
334                    self.line_at(start)
335                ));
336            };
337            self.i += 1;
338            if quote == b'\'' {
339                // Single quotes: only `\\` and `\'` are escapes; any other backslash stays.
340                match e {
341                    b'\\' | b'\'' => out.push(e),
342                    _ => {
343                        out.push(b'\\');
344                        out.push(e);
345                    }
346                }
347            } else {
348                match e {
349                    b'n' => out.push(b'\n'),
350                    b't' => out.push(b'\t'),
351                    b'r' => out.push(b'\r'),
352                    b'v' => out.push(0x0b),
353                    b'e' => out.push(0x1b),
354                    b'f' => out.push(0x0c),
355                    b'\\' | b'"' | b'$' => out.push(e),
356                    b'0'..=b'7' => {
357                        let mut n = u32::from(e - b'0');
358                        for _ in 0..2 {
359                            match self.peek() {
360                                Some(d @ b'0'..=b'7') => {
361                                    n = n * 8 + u32::from(d - b'0');
362                                    self.i += 1;
363                                }
364                                _ => break,
365                            }
366                        }
367                        out.push((n & 0xff) as u8);
368                    }
369                    b'x' => {
370                        let mut n = 0u32;
371                        let mut got = 0;
372                        while got < 2 {
373                            match self.peek().and_then(|d| (d as char).to_digit(16)) {
374                                Some(d) => {
375                                    n = n * 16 + d;
376                                    self.i += 1;
377                                    got += 1;
378                                }
379                                None => break,
380                            }
381                        }
382                        if got == 0 {
383                            out.extend_from_slice(b"\\x");
384                        } else {
385                            out.push(n as u8);
386                        }
387                    }
388                    _ => {
389                        out.push(b'\\');
390                        out.push(e);
391                    }
392                }
393            }
394        }
395        // `$name` inside double quotes would be interpolated by PHP; it is kept
396        // literally, and the caller sees a `$` in the value.
397        Ok(String::from_utf8_lossy(&out).into_owned())
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde_json::json;
405
406    #[test]
407    fn reads_the_shape_nextcloud_writes() {
408        let src = r#"<?php
409$CONFIG = array (
410  'passwordsalt' => 'abc\'def',
411  'secret' => "line\nbreak $x",
412  'trusted_domains' =>
413  array (
414    0 => 'localhost',
415    1 => 'cloud.example.com',
416  ),
417  'dbtype' => 'sqlite3',
418  'version' => '29.0.4.1',
419  'installed' => true,
420  'maintenance' => false,
421  'port' => 3306,
422  'ratio' => 1.5,
423  'objectstore' =>
424  array (
425    'class' => '\\OC\\Files\\ObjectStore\\S3',
426    'arguments' =>
427    array (
428      'bucket' => 'b',
429      'key' => 'AKIA',
430      'secret' => 's3cret',
431    ),
432  ),
433);
434"#;
435        let p = parse(src).unwrap();
436        assert!(p.warnings.is_empty(), "{:?}", p.warnings);
437        assert_eq!(p.value["passwordsalt"], "abc'def");
438        assert_eq!(p.value["secret"], "line\nbreak $x");
439        assert_eq!(
440            p.value["trusted_domains"],
441            json!(["localhost", "cloud.example.com"])
442        );
443        assert_eq!(p.value["installed"], true);
444        assert_eq!(p.value["port"], 3306);
445        assert_eq!(p.value["ratio"], 1.5);
446        assert_eq!(
447            p.value["objectstore"]["class"],
448            "\\OC\\Files\\ObjectStore\\S3"
449        );
450        assert_eq!(p.value["objectstore"]["arguments"]["secret"], "s3cret");
451    }
452
453    #[test]
454    fn short_arrays_comments_and_trailing_commas() {
455        let p = parse("<?php\n// a\n$c = [ # b\n 'a' => 1, /* c */ 'b' => [1, 2,], ];").unwrap();
456        assert_eq!(p.value, json!({"a": 1, "b": [1, 2]}));
457    }
458
459    #[test]
460    fn expressions_that_need_php_are_left_out_and_reported() {
461        let p = parse(
462            "<?php $c = array('a' => getenv('X'), 'b' => 'x' . 'y', 'c' => OC::$ROOT . '/d', 'e' => 'ok');",
463        )
464        .unwrap();
465        assert_eq!(p.value["e"], "ok");
466        assert_eq!(p.value["a"], Value::Null);
467        assert_eq!(p.value["b"], Value::Null);
468        assert_eq!(p.value["c"], Value::Null);
469        assert_eq!(p.warnings.len(), 3, "{:?}", p.warnings);
470    }
471
472    #[test]
473    fn reads_the_named_variable_from_a_script_and_not_the_first_equals() {
474        let src = "<?php\n$use = getenv('X');\nif ($use) {\n  $CONFIG = array('a' => getenv('A') ?: 'd', 'b' => 'ok');\n}";
475        assert!(parse(src).is_err(), "the first `=` is not an array");
476        let p = parse_var(src, "CONFIG").unwrap();
477        assert_eq!(p.value["b"], "ok");
478        assert!(!p.warnings.is_empty());
479        assert!(parse_var("<?php $CONFIGX = [1];", "CONFIG").is_err());
480        assert!(parse_var("<?php $CONFIG == [1];", "CONFIG").is_err());
481    }
482
483    #[test]
484    fn refuses_what_it_cannot_bound_or_close() {
485        assert!(parse("<?php $c = array('a' => 'unterminated);").is_err());
486        assert!(parse("<?php nothing here").is_err());
487        assert!(parse("<?php $c = array('a' => 1").is_err());
488        let deep = format!("<?php $c = {}{}", "[".repeat(200), "]".repeat(200));
489        assert!(parse(&deep).unwrap_err().contains("nest deeper"));
490        let wide = format!("<?php $c = [{}];", "1,".repeat(150_000));
491        assert!(parse(&wide).unwrap_err().contains("too many"));
492    }
493}