Skip to main content

nextcloud_config_parser/
nc.rs

1use crate::{
2    split_host, Config, Database, DbConnect, DbError, Error, NotAConfigError, PhpParseError,
3    RedisClusterConnectionInfo, RedisConnectionInfo, RedisTlsParams, Result, SslOptions,
4};
5use crate::{RedisConfig, RedisConnectionAddr};
6use indexmap::IndexMap;
7use php_literal_parser::Value;
8use std::fs::DirEntry;
9use std::iter::once;
10use std::net::IpAddr;
11use std::path::{Path, PathBuf};
12use std::str::FromStr;
13
14static CONFIG_CONSTANTS: &[(&str, &str)] = &[
15    (r"\RedisCluster::FAILOVER_NONE", "0"),
16    (r"\RedisCluster::FAILOVER_ERROR", "1"),
17    (r"\RedisCluster::DISTRIBUTE", "2"),
18    (r"\RedisCluster::FAILOVER_DISTRIBUTE_SLAVES", "3"),
19    (r"\PDO::MYSQL_ATTR_SSL_KEY", "1007"),
20    (r"\PDO::MYSQL_ATTR_SSL_CERT", "1008"),
21    (r"\PDO::MYSQL_ATTR_SSL_CA", "1009"),
22    (r"\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT", "1014"),
23];
24
25fn glob_config_files(path: impl AsRef<Path>) -> impl Iterator<Item = PathBuf> {
26    let main: PathBuf = path.as_ref().into();
27    let files = if let Some(parent) = path.as_ref().parent() {
28        if let Ok(dir) = parent.read_dir() {
29            Some(dir.filter_map(Result::ok).filter_map(|file: DirEntry| {
30                let path = file.path();
31                match path.to_str() {
32                    Some(path_str) if path_str.ends_with(".config.php") => Some(path),
33                    _ => None,
34                }
35            }))
36        } else {
37            None
38        }
39    } else {
40        None
41    };
42
43    once(main).chain(files.into_iter().flatten())
44}
45
46fn parse_php(path: impl AsRef<Path>) -> Result<Value> {
47    let mut content = std::fs::read_to_string(&path)
48        .map_err(|err| Error::ReadFailed(err, path.as_ref().into()))?;
49
50    for (search, replace) in CONFIG_CONSTANTS {
51        if content.contains(search) {
52            content = content.replace(search, replace);
53        }
54    }
55
56    let php = match content.rfind("$CONFIG") {
57        Some(pos) => content[pos + "$CONFIG".len()..]
58            .trim()
59            .trim_start_matches('='),
60        None => {
61            return Err(Error::NotAConfig(NotAConfigError::NoConfig(
62                path.as_ref().into(),
63            )));
64        }
65    };
66    php_literal_parser::from_str(php).map_err(|err| {
67        Error::Php(PhpParseError {
68            err,
69            path: path.as_ref().into(),
70        })
71    })
72}
73
74fn merge_configs(input: Vec<(PathBuf, Value)>) -> Result<Value> {
75    let mut merged = IndexMap::with_capacity(16);
76
77    for (path, config) in input {
78        match config.into_map() {
79            Some(map) => {
80                for (key, value) in map {
81                    merged.insert(key, value);
82                }
83            }
84            None => {
85                return Err(Error::NotAConfig(NotAConfigError::NotAnArray(path)));
86            }
87        }
88    }
89
90    Ok(Value::Array(merged))
91}
92
93fn parse_files(files: impl IntoIterator<Item = PathBuf>) -> Result<Config> {
94    let parsed_files = files
95        .into_iter()
96        .map(|path| {
97            let parsed = parse_php(&path)?;
98            Result::<_, Error>::Ok((path, parsed))
99        })
100        .collect::<Result<Vec<_>, _>>()?;
101    let parsed = merge_configs(parsed_files)?;
102
103    let database = parse_db_options(&parsed)?;
104    let database_prefix = parsed["dbtableprefix"]
105        .as_str()
106        .unwrap_or("oc_")
107        .to_string();
108    let nextcloud_url = parsed["overwrite.cli.url"]
109        .clone()
110        .into_string()
111        .ok_or(Error::NoUrl)?;
112    let redis = parse_redis_options(&parsed, "redis");
113    let notify_push_redis = if parsed["notify_push_redis"].is_array() {
114        Some(parse_redis_options(&parsed, "notify_push_redis"))
115    } else {
116        None
117    };
118
119    Ok(Config {
120        database,
121        database_prefix,
122        nextcloud_url,
123        redis,
124        notify_push_redis,
125    })
126}
127
128pub fn parse(path: impl AsRef<Path>) -> Result<Config> {
129    parse_files(once(path.as_ref().into()))
130}
131
132pub fn parse_glob(path: impl AsRef<Path>) -> Result<Config> {
133    parse_files(glob_config_files(path))
134}
135
136fn parse_db_options(parsed: &Value) -> Result<Database> {
137    match parsed["dbtype"].as_str() {
138        Some("mysql") => {
139            let username = parsed["dbuser"].as_str().ok_or(DbError::NoUsername)?;
140            let password = parsed["dbpassword"].as_str().ok_or(DbError::NoPassword)?;
141            let socket_addr1 = PathBuf::from("/var/run/mysqld/mysqld.sock");
142            let socket_addr2 = PathBuf::from("/tmp/mysql.sock");
143            let socket_addr3 = PathBuf::from("/run/mysql/mysql.sock");
144            let (mut connect, disable_ssl) =
145                match split_host(parsed["dbhost"].as_str().unwrap_or_default()) {
146                    ("localhost", None, None) if socket_addr1.exists() => {
147                        (DbConnect::Socket(socket_addr1), false)
148                    }
149                    ("localhost", None, None) if socket_addr2.exists() => {
150                        (DbConnect::Socket(socket_addr2), false)
151                    }
152                    ("localhost", None, None) if socket_addr3.exists() => {
153                        (DbConnect::Socket(socket_addr3), false)
154                    }
155                    (addr, None, None) => (
156                        DbConnect::Tcp {
157                            host: addr.into(),
158                            port: 3306,
159                        },
160                        IpAddr::from_str(addr).is_ok(),
161                    ),
162                    (addr, Some(port), None) => (
163                        DbConnect::Tcp {
164                            host: addr.into(),
165                            port,
166                        },
167                        IpAddr::from_str(addr).is_ok(),
168                    ),
169                    (_, None, Some(socket)) => (DbConnect::Socket(socket.into()), false),
170                    (_, Some(_), Some(_)) => {
171                        unreachable!()
172                    }
173                };
174            if let Some(port) = parse_port(&parsed["dbport"]) {
175                if let DbConnect::Tcp {
176                    port: connect_port, ..
177                } = &mut connect
178                {
179                    *connect_port = port;
180                }
181            }
182            let database = parsed["dbname"].as_str().unwrap_or("owncloud");
183
184            let verify = parsed["dbdriveroptions"][1014] // MYSQL_ATTR_SSL_VERIFY_SERVER_CERT
185                .clone()
186                .into_bool()
187                .unwrap_or(true);
188
189            let ssl_options = if let (Some(ssl_key), Some(ssl_cert), Some(ssl_ca)) = (
190                parsed["dbdriveroptions"][1007].as_str(), // MYSQL_ATTR_SSL_KEY
191                parsed["dbdriveroptions"][1008].as_str(), // MYSQL_ATTR_SSL_CERT
192                parsed["dbdriveroptions"][1009].as_str(), // MYSQL_ATTR_SSL_CA
193            ) {
194                SslOptions::Enabled {
195                    key: ssl_key.into(),
196                    cert: ssl_cert.into(),
197                    ca: ssl_ca.into(),
198                    verify,
199                }
200                // if MYSQL_ATTR_SSL_VERIFY_SERVER_CERT is disabled, we should be able to use ssl even with raw ip
201            } else if disable_ssl && verify {
202                SslOptions::Disabled
203            } else {
204                SslOptions::Default
205            };
206
207            Ok(Database::MySql {
208                database: database.into(),
209                username: username.into(),
210                password: password.into(),
211                connect,
212                ssl_options,
213            })
214        }
215        Some("pgsql") => {
216            let username = parsed["dbuser"].as_str().ok_or(DbError::NoUsername)?;
217            let password = parsed["dbpassword"].as_str().unwrap_or_default();
218            let db_host = parsed["dbhost"].as_str().unwrap_or_default();
219            let mut host_parts = db_host.split(';');
220            let (mut connect, disable_ssl) =
221                match split_host(host_parts.next().expect("empty split")) {
222                    (addr, None, None) => (
223                        DbConnect::Tcp {
224                            host: addr.into(),
225                            port: 5432,
226                        },
227                        IpAddr::from_str(addr).is_ok(),
228                    ),
229                    (addr, Some(port), None) => (
230                        DbConnect::Tcp {
231                            host: addr.into(),
232                            port,
233                        },
234                        IpAddr::from_str(addr).is_ok(),
235                    ),
236                    (_, None, Some(socket)) => {
237                        let mut socket_path = Path::new(socket);
238
239                        // sqlx wants the folder the socket is in, not the socket itself
240                        if socket_path
241                            .file_name()
242                            .map(|name| name.to_str().unwrap().starts_with(".s"))
243                            .unwrap_or(false)
244                        {
245                            socket_path = socket_path.parent().unwrap();
246                        }
247                        (DbConnect::Socket(socket_path.into()), false)
248                    }
249                    (_, Some(_), Some(_)) => {
250                        unreachable!()
251                    }
252                };
253
254            let mut options = IndexMap::new();
255            for part in host_parts {
256                if let Some((key, value)) = part.split_once('=') {
257                    options.insert(key.into(), value.into());
258                }
259            }
260
261            if let Some(port) = parse_port(&parsed["dbport"]) {
262                if let DbConnect::Tcp {
263                    port: connect_port, ..
264                } = &mut connect
265                {
266                    *connect_port = port;
267                }
268            }
269            if let Some(ssl_options) = parsed["pgsql_ssl"].clone().into_map() {
270                if let Some(mode) = ssl_options["mode"].as_str() {
271                    options.insert("sslmode".into(), mode.into());
272                }
273                if let Some(mode) = ssl_options["cert"].as_str() {
274                    options.insert("sslcert".into(), mode.into());
275                }
276                if let Some(mode) = ssl_options["rootcert"].as_str() {
277                    options.insert("sslrootcert".into(), mode.into());
278                }
279                if let Some(mode) = ssl_options["key"].as_str() {
280                    options.insert("sslkey".into(), mode.into());
281                }
282                if let Some(mode) = ssl_options["crl"].as_str() {
283                    options.insert("sslcrl".into(), mode.into());
284                }
285            } else if disable_ssl {
286                options.insert("sslmode".into(), "disable".into());
287            }
288
289            let database = parsed["dbname"]
290                .as_str()
291                .or_else(|| options.get("dbname").map(String::as_str))
292                .unwrap_or("owncloud");
293
294            Ok(Database::Postgres {
295                database: database.into(),
296                username: username.into(),
297                password: password.into(),
298                connect,
299                options,
300            })
301        }
302        Some("sqlite3") | Some("sqlite") | None => {
303            let data_dir = parsed["datadirectory"]
304                .as_str()
305                .ok_or(DbError::NoDataDirectory)?;
306            let db_name = parsed["dbname"].as_str().unwrap_or("owncloud");
307            Ok(Database::Sqlite {
308                database: format!("{data_dir}/{db_name}.db").into(),
309            })
310        }
311        Some(ty) => Err(Error::InvalidDb(DbError::Unsupported(ty.into()))),
312    }
313}
314
315enum RedisAddress {
316    Single(RedisConnectionAddr),
317    Cluster(Vec<RedisConnectionAddr>),
318}
319
320fn parse_redis_options(parsed: &Value, key: &str) -> RedisConfig {
321    let cluster_key = format!("{key}.cluster");
322    let cluster_key = cluster_key.as_str();
323
324    let (redis_options, address) = if parsed[cluster_key].is_array() {
325        let redis_options = &parsed[cluster_key];
326        let seeds = redis_options["seeds"].values();
327        let mut addresses = seeds
328            .filter_map(|seed| seed.as_str())
329            .map(|seed| {
330                RedisConnectionAddr::parse(seed, None, redis_options["ssl_context"].is_array())
331            })
332            .collect::<Vec<_>>();
333        addresses.sort();
334        (redis_options, RedisAddress::Cluster(addresses))
335    } else {
336        let redis_options = &parsed[key];
337        let host = redis_options["host"].as_str().unwrap_or("127.0.0.1");
338        let address = RedisAddress::Single(RedisConnectionAddr::parse(
339            host,
340            redis_options["port"]
341                .as_int()
342                .and_then(|port| u16::try_from(port).ok()),
343            redis_options["ssl_context"].is_array(),
344        ));
345        (redis_options, address)
346    };
347
348    let tls_params = if redis_options["ssl_context"].is_array() {
349        let ssl_options = &redis_options["ssl_context"];
350        Some(RedisTlsParams {
351            local_cert: ssl_options["local_cert"].as_str().map(From::from),
352            local_pk: ssl_options["local_pk"].as_str().map(From::from),
353            ca_file: ssl_options["cafile"].as_str().map(From::from),
354            accept_invalid_hostname: ssl_options["verify_peer_name"] == false,
355            insecure: ssl_options["verify_peer "] == false,
356        })
357    } else {
358        None
359    };
360
361    let db = redis_options["dbindex"]
362        .clone()
363        .into_int()
364        .or_else(|| {
365            redis_options["dbindex"]
366                .as_str()
367                .and_then(|i| i64::from_str(i).ok())
368        })
369        .unwrap_or(0);
370    let password = redis_options["password"]
371        .as_str()
372        .filter(|pass| !pass.is_empty())
373        .map(String::from);
374    let username = redis_options["user"]
375        .as_str()
376        .filter(|user| !user.is_empty())
377        .map(String::from);
378
379    match address {
380        RedisAddress::Single(addr) => RedisConfig::Single(RedisConnectionInfo {
381            addr,
382            db,
383            username,
384            password,
385            tls_params,
386        }),
387        RedisAddress::Cluster(addr) => RedisConfig::Cluster(RedisClusterConnectionInfo {
388            addr,
389            db,
390            username,
391            password,
392            tls_params,
393        }),
394    }
395}
396
397fn parse_port(port: &Value) -> Option<u16> {
398    port.as_str()
399        .and_then(|port| port.parse().ok())
400        .or_else(|| port.as_int().map(|port| port as u16))
401}
402
403#[test]
404fn test_redis_empty_password_none() {
405    let config =
406        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => "pass"]]"#)
407            .unwrap();
408    let redis = parse_redis_options(&config, "redis");
409    assert_eq!(redis.passwd(), Some("pass"));
410
411    let config =
412        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => ""]]"#)
413            .unwrap();
414    let redis = parse_redis_options(&config, "redis");
415    assert_eq!(redis.passwd(), None);
416}
417
418#[test]
419fn test_postgres_port() {
420    use indexmap::indexmap;
421
422    let config = php_literal_parser::from_str(
423        r#"[
424            'dbtype' => 'pgsql',
425            'dbhost' => '127.0.0.1:6432',
426            'dbport' => '',
427            'dbuser' => 'nextcloud',
428            'dbpassword' => 'nextcloud',
429            'dbname' => 'nextcloud',
430        ]"#,
431    )
432    .unwrap();
433    let db = parse_db_options(&config).unwrap();
434    assert_eq!(
435        db,
436        Database::Postgres {
437            database: "nextcloud".to_string(),
438            username: "nextcloud".to_string(),
439            password: "nextcloud".to_string(),
440            connect: DbConnect::Tcp {
441                host: "127.0.0.1".into(),
442                port: 6432,
443            },
444            options: indexmap! {
445                "sslmode".into() => "disable".into(),
446            },
447        }
448    );
449    assert_eq!(
450        db.url(),
451        "postgresql://nextcloud:nextcloud@127.0.0.1:6432/nextcloud?sslmode=disable"
452    );
453
454    let config = php_literal_parser::from_str(
455        r#"[
456            'dbtype' => 'pgsql',
457            'dbhost' => '127.0.0.1',
458            'dbport' => '6432',
459            'dbuser' => 'nextcloud',
460            'dbpassword' => 'nextcloud',
461            'dbname' => 'nextcloud',
462        ]"#,
463    )
464    .unwrap();
465    let db = parse_db_options(&config).unwrap();
466    assert_eq!(
467        db,
468        Database::Postgres {
469            database: "nextcloud".to_string(),
470            username: "nextcloud".to_string(),
471            password: "nextcloud".to_string(),
472            connect: DbConnect::Tcp {
473                host: "127.0.0.1".into(),
474                port: 6432,
475            },
476            options: indexmap! {
477                "sslmode".into() => "disable".into(),
478            },
479        }
480    );
481    assert_eq!(
482        db.url(),
483        "postgresql://nextcloud:nextcloud@127.0.0.1:6432/nextcloud?sslmode=disable"
484    );
485}
486
487#[test]
488fn test_postgres_options() {
489    use indexmap::indexmap;
490
491    let config =
492        php_literal_parser::from_str(r#"[
493            'dbtype' => 'pgsql',
494            'dbhost' => 'db.example.org;sslmode=verify-ca;sslrootcert=/etc/ssl/certs/ca-certificates.crt;dbname=nextcloud',
495            'dbuser' => 'nextcloud',
496            'dbpassword' => 'nextcloud',
497        ]"#)
498            .unwrap();
499    let db = parse_db_options(&config).unwrap();
500    assert_eq!(
501        db,
502        Database::Postgres {
503            database: "nextcloud".to_string(),
504            username: "nextcloud".to_string(),
505            password: "nextcloud".to_string(),
506            connect: DbConnect::Tcp {
507                host: "db.example.org".into(),
508                port: 5432,
509            },
510            options: indexmap! {
511                "sslmode".into() => "verify-ca".into(),
512                "sslrootcert".into() => "/etc/ssl/certs/ca-certificates.crt".into(),
513                "dbname".into() => "nextcloud".into(),
514            },
515        }
516    );
517    assert_eq!(db.url(), "postgresql://nextcloud:nextcloud@db.example.org/nextcloud?sslmode=verify-ca&sslrootcert=/etc/ssl/certs/ca-certificates.crt&dbname=nextcloud");
518}