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 php_literal_parser::Value;
7use std::collections::HashMap;
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 = HashMap::with_capacity(16);
76
77    for (path, config) in input {
78        match config.into_hashmap() {
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) = parsed["dbport"].clone().into_int() {
175                if let DbConnect::Tcp {
176                    port: connect_port, ..
177                } = &mut connect
178                {
179                    *connect_port = port as u16;
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 (mut connect, disable_ssl) =
219                match split_host(parsed["dbhost"].as_str().unwrap_or_default()) {
220                    (addr, None, None) => (
221                        DbConnect::Tcp {
222                            host: addr.into(),
223                            port: 5432,
224                        },
225                        IpAddr::from_str(addr).is_ok(),
226                    ),
227                    (addr, Some(port), None) => (
228                        DbConnect::Tcp {
229                            host: addr.into(),
230                            port,
231                        },
232                        IpAddr::from_str(addr).is_ok(),
233                    ),
234                    (_, None, Some(socket)) => {
235                        let mut socket_path = Path::new(socket);
236
237                        // sqlx wants the folder the socket is in, not the socket itself
238                        if socket_path
239                            .file_name()
240                            .map(|name| name.to_str().unwrap().starts_with(".s"))
241                            .unwrap_or(false)
242                        {
243                            socket_path = socket_path.parent().unwrap();
244                        }
245                        (DbConnect::Socket(socket_path.into()), false)
246                    }
247                    (_, Some(_), Some(_)) => {
248                        unreachable!()
249                    }
250                };
251            if let Some(port) = parsed["dbport"].clone().into_int() {
252                if let DbConnect::Tcp {
253                    port: connect_port, ..
254                } = &mut connect
255                {
256                    *connect_port = port as u16;
257                }
258            }
259            let database = parsed["dbname"].as_str().unwrap_or("owncloud");
260
261            let ssl_options = if disable_ssl {
262                SslOptions::Disabled
263            } else {
264                SslOptions::Default
265            };
266
267            Ok(Database::Postgres {
268                database: database.into(),
269                username: username.into(),
270                password: password.into(),
271                connect,
272                ssl_options,
273            })
274        }
275        Some("sqlite3") | Some("sqlite") | None => {
276            let data_dir = parsed["datadirectory"]
277                .as_str()
278                .ok_or(DbError::NoDataDirectory)?;
279            let db_name = parsed["dbname"].as_str().unwrap_or("owncloud");
280            Ok(Database::Sqlite {
281                database: format!("{data_dir}/{db_name}.db").into(),
282            })
283        }
284        Some(ty) => Err(Error::InvalidDb(DbError::Unsupported(ty.into()))),
285    }
286}
287
288enum RedisAddress {
289    Single(RedisConnectionAddr),
290    Cluster(Vec<RedisConnectionAddr>),
291}
292
293fn parse_redis_options(parsed: &Value, key: &str) -> RedisConfig {
294    let cluster_key = format!("{key}.cluster");
295    let cluster_key = cluster_key.as_str();
296
297    let (redis_options, address) = if parsed[cluster_key].is_array() {
298        let redis_options = &parsed[cluster_key];
299        let seeds = redis_options["seeds"].values();
300        let mut addresses = seeds
301            .filter_map(|seed| seed.as_str())
302            .map(|seed| {
303                RedisConnectionAddr::parse(seed, None, redis_options["ssl_context"].is_array())
304            })
305            .collect::<Vec<_>>();
306        addresses.sort();
307        (redis_options, RedisAddress::Cluster(addresses))
308    } else {
309        let redis_options = &parsed[key];
310        let host = redis_options["host"].as_str().unwrap_or("127.0.0.1");
311        let address = RedisAddress::Single(RedisConnectionAddr::parse(
312            host,
313            redis_options["port"]
314                .as_int()
315                .and_then(|port| u16::try_from(port).ok()),
316            redis_options["ssl_context"].is_array(),
317        ));
318        (redis_options, address)
319    };
320
321    let tls_params = if redis_options["ssl_context"].is_array() {
322        let ssl_options = &redis_options["ssl_context"];
323        Some(RedisTlsParams {
324            local_cert: ssl_options["local_cert"].as_str().map(From::from),
325            local_pk: ssl_options["local_pk"].as_str().map(From::from),
326            ca_file: ssl_options["cafile"].as_str().map(From::from),
327            accept_invalid_hostname: ssl_options["verify_peer_name"] == false,
328            insecure: ssl_options["verify_peer "] == false,
329        })
330    } else {
331        None
332    };
333
334    let db = redis_options["dbindex"].clone().into_int().or_else(|| {
335        redis_options["dbindex"]
336            .as_str()
337            .and_then(|i| i64::from_str(i).ok())
338    }).unwrap_or(0);
339    let password = redis_options["password"]
340        .as_str()
341        .filter(|pass| !pass.is_empty())
342        .map(String::from);
343    let username = redis_options["user"]
344        .as_str()
345        .filter(|user| !user.is_empty())
346        .map(String::from);
347
348    match address {
349        RedisAddress::Single(addr) => RedisConfig::Single(RedisConnectionInfo {
350            addr,
351            db,
352            username,
353            password,
354            tls_params,
355        }),
356        RedisAddress::Cluster(addr) => RedisConfig::Cluster(RedisClusterConnectionInfo {
357            addr,
358            db,
359            username,
360            password,
361            tls_params,
362        }),
363    }
364}
365
366#[test]
367fn test_redis_empty_password_none() {
368    let config =
369        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => "pass"]]"#)
370            .unwrap();
371    let redis = parse_redis_options(&config, "redis");
372    assert_eq!(redis.passwd(), Some("pass"));
373
374    let config =
375        php_literal_parser::from_str(r#"["redis" => ["host" => "redis", "password" => ""]]"#)
376            .unwrap();
377    let redis = parse_redis_options(&config, "redis");
378    assert_eq!(redis.passwd(), None);
379}