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.find("$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 disable_ssl {
270                options.insert("sslmode".into(), "disable".into());
271            }
272
273            let database = parsed["dbname"]
274                .as_str()
275                .or_else(|| options.get("dbname").map(String::as_str))
276                .unwrap_or("owncloud");
277
278            Ok(Database::Postgres {
279                database: database.into(),
280                username: username.into(),
281                password: password.into(),
282                connect,
283                options,
284            })
285        }
286        Some("sqlite3") | Some("sqlite") | None => {
287            let data_dir = parsed["datadirectory"]
288                .as_str()
289                .ok_or(DbError::NoDataDirectory)?;
290            let db_name = parsed["dbname"].as_str().unwrap_or("owncloud");
291            Ok(Database::Sqlite {
292                database: format!("{data_dir}/{db_name}.db").into(),
293            })
294        }
295        Some(ty) => Err(Error::InvalidDb(DbError::Unsupported(ty.into()))),
296    }
297}
298
299enum RedisAddress {
300    Single(RedisConnectionAddr),
301    Cluster(Vec<RedisConnectionAddr>),
302}
303
304fn parse_redis_options(parsed: &Value, key: &str) -> RedisConfig {
305    let cluster_key = format!("{key}.cluster");
306    let cluster_key = cluster_key.as_str();
307
308    let (redis_options, address) = if parsed[cluster_key].is_array() {
309        let redis_options = &parsed[cluster_key];
310        let seeds = redis_options["seeds"].values();
311        let mut addresses = seeds
312            .filter_map(|seed| seed.as_str())
313            .map(|seed| {
314                RedisConnectionAddr::parse(seed, None, redis_options["ssl_context"].is_array())
315            })
316            .collect::<Vec<_>>();
317        addresses.sort();
318        (redis_options, RedisAddress::Cluster(addresses))
319    } else {
320        let redis_options = &parsed[key];
321        let host = redis_options["host"].as_str().unwrap_or("127.0.0.1");
322        let address = RedisAddress::Single(RedisConnectionAddr::parse(
323            host,
324            redis_options["port"]
325                .as_int()
326                .and_then(|port| u16::try_from(port).ok()),
327            redis_options["ssl_context"].is_array(),
328        ));
329        (redis_options, address)
330    };
331
332    let tls_params = if redis_options["ssl_context"].is_array() {
333        let ssl_options = &redis_options["ssl_context"];
334        Some(RedisTlsParams {
335            local_cert: ssl_options["local_cert"].as_str().map(From::from),
336            local_pk: ssl_options["local_pk"].as_str().map(From::from),
337            ca_file: ssl_options["cafile"].as_str().map(From::from),
338            accept_invalid_hostname: ssl_options["verify_peer_name"] == false,
339            insecure: ssl_options["verify_peer "] == false,
340        })
341    } else {
342        None
343    };
344
345    let db = redis_options["dbindex"]
346        .clone()
347        .into_int()
348        .or_else(|| {
349            redis_options["dbindex"]
350                .as_str()
351                .and_then(|i| i64::from_str(i).ok())
352        })
353        .unwrap_or(0);
354    let password = redis_options["password"]
355        .as_str()
356        .filter(|pass| !pass.is_empty())
357        .map(String::from);
358    let username = redis_options["user"]
359        .as_str()
360        .filter(|user| !user.is_empty())
361        .map(String::from);
362
363    match address {
364        RedisAddress::Single(addr) => RedisConfig::Single(RedisConnectionInfo {
365            addr,
366            db,
367            username,
368            password,
369            tls_params,
370        }),
371        RedisAddress::Cluster(addr) => RedisConfig::Cluster(RedisClusterConnectionInfo {
372            addr,
373            db,
374            username,
375            password,
376            tls_params,
377        }),
378    }
379}
380
381fn parse_port(port: &Value) -> Option<u16> {
382    port.as_str()
383        .and_then(|port| port.parse().ok())
384        .or_else(|| port.as_int().map(|port| port as u16))
385}
386
387#[test]
388fn test_redis_empty_password_none() {
389    let config =
390        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => "pass"]]"#)
391            .unwrap();
392    let redis = parse_redis_options(&config, "redis");
393    assert_eq!(redis.passwd(), Some("pass"));
394
395    let config =
396        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => ""]]"#)
397            .unwrap();
398    let redis = parse_redis_options(&config, "redis");
399    assert_eq!(redis.passwd(), None);
400}
401
402#[test]
403fn test_postgres_port() {
404    use indexmap::indexmap;
405
406    let config = php_literal_parser::from_str(
407        r#"[
408            'dbtype' => 'pgsql',
409            'dbhost' => '127.0.0.1:6432',
410            'dbport' => '',
411            'dbuser' => 'nextcloud',
412            'dbpassword' => 'nextcloud',
413            'dbname' => 'nextcloud',
414        ]"#,
415    )
416    .unwrap();
417    let db = parse_db_options(&config).unwrap();
418    assert_eq!(
419        db,
420        Database::Postgres {
421            database: "nextcloud".to_string(),
422            username: "nextcloud".to_string(),
423            password: "nextcloud".to_string(),
424            connect: DbConnect::Tcp {
425                host: "127.0.0.1".into(),
426                port: 6432,
427            },
428            options: indexmap! {
429                "sslmode".into() => "disable".into(),
430            },
431        }
432    );
433    assert_eq!(
434        db.url(),
435        "postgresql://nextcloud:nextcloud@127.0.0.1:6432/nextcloud?sslmode=disable"
436    );
437
438    let config = php_literal_parser::from_str(
439        r#"[
440            'dbtype' => 'pgsql',
441            'dbhost' => '127.0.0.1',
442            'dbport' => '6432',
443            'dbuser' => 'nextcloud',
444            'dbpassword' => 'nextcloud',
445            'dbname' => 'nextcloud',
446        ]"#,
447    )
448    .unwrap();
449    let db = parse_db_options(&config).unwrap();
450    assert_eq!(
451        db,
452        Database::Postgres {
453            database: "nextcloud".to_string(),
454            username: "nextcloud".to_string(),
455            password: "nextcloud".to_string(),
456            connect: DbConnect::Tcp {
457                host: "127.0.0.1".into(),
458                port: 6432,
459            },
460            options: indexmap! {
461                "sslmode".into() => "disable".into(),
462            },
463        }
464    );
465    assert_eq!(
466        db.url(),
467        "postgresql://nextcloud:nextcloud@127.0.0.1:6432/nextcloud?sslmode=disable"
468    );
469}
470
471#[test]
472fn test_postgres_options() {
473    use indexmap::indexmap;
474
475    let config =
476        php_literal_parser::from_str(r#"[
477            'dbtype' => 'pgsql',
478            'dbhost' => 'db.example.org;sslmode=verify-ca;sslrootcert=/etc/ssl/certs/ca-certificates.crt;dbname=nextcloud',
479            'dbuser' => 'nextcloud',
480            'dbpassword' => 'nextcloud',
481        ]"#)
482            .unwrap();
483    let db = parse_db_options(&config).unwrap();
484    assert_eq!(
485        db,
486        Database::Postgres {
487            database: "nextcloud".to_string(),
488            username: "nextcloud".to_string(),
489            password: "nextcloud".to_string(),
490            connect: DbConnect::Tcp {
491                host: "db.example.org".into(),
492                port: 5432,
493            },
494            options: indexmap! {
495                "sslmode".into() => "verify-ca".into(),
496                "sslrootcert".into() => "/etc/ssl/certs/ca-certificates.crt".into(),
497                "dbname".into() => "nextcloud".into(),
498            },
499        }
500    );
501    assert_eq!(db.url(), "postgresql://nextcloud:nextcloud@db.example.org/nextcloud?sslmode=verify-ca&sslrootcert=/etc/ssl/certs/ca-certificates.crt&dbname=nextcloud");
502}