mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
matrix: config loading now merges optional TOML with CLI/env/secret inputs (#617)
* matrix: config loading now merges optional TOML with CLI/env/secret inputs * matrix: fix tests * matrix: address review comments * matrix: fix tests * matrix: cover missing unit test vectors
This commit is contained in:
143
matrix/Cargo.lock
generated
143
matrix/Cargo.lock
generated
@@ -11,6 +11,56 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.100"
|
||||
@@ -125,9 +175,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.51"
|
||||
version = "1.2.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
|
||||
checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -145,6 +195,52 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.5.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.0.0"
|
||||
@@ -214,9 +310,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
@@ -351,6 +447,12 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -606,6 +708,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -630,9 +738,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.179"
|
||||
version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -762,6 +870,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
@@ -859,6 +973,7 @@ version = "0.5.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"mockito",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -1364,6 +1479,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1551,9 +1672,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.10+spec-1.1.0"
|
||||
version = "0.9.11+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48"
|
||||
checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -1738,6 +1859,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
||||
@@ -28,6 +28,7 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
urlencoding = "2"
|
||||
axum = { version = "0.7", features = ["json"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
FROM rust:1.92-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -56,9 +56,17 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is in `Config.toml` in the project root.
|
||||
Configuration can come from a TOML file, CLI flags, environment variables, or secret files. The bridge merges inputs in this order (highest to lowest):
|
||||
|
||||
Example:
|
||||
1. CLI flags
|
||||
2. Environment variables
|
||||
3. Secret files (`*_FILE` paths or container defaults)
|
||||
4. TOML config file
|
||||
5. Container defaults (paths + poll interval)
|
||||
|
||||
If no TOML file is provided, required values must be supplied via CLI/env/secret inputs.
|
||||
|
||||
Example TOML:
|
||||
|
||||
```toml
|
||||
[potatomesh]
|
||||
@@ -86,6 +94,58 @@ state_file = "bridge_state.json"
|
||||
|
||||
The `hs_token` is used to validate inbound appservice transactions. Keep it identical in `Config.toml` and your Matrix appservice registration file.
|
||||
|
||||
### CLI Flags
|
||||
|
||||
Run `potatomesh-matrix-bridge --help` for the full list. Common flags:
|
||||
|
||||
* `--config PATH`
|
||||
* `--state-file PATH`
|
||||
* `--potatomesh-base-url URL`
|
||||
* `--potatomesh-poll-interval-secs SECS`
|
||||
* `--matrix-homeserver URL`
|
||||
* `--matrix-as-token TOKEN`
|
||||
* `--matrix-as-token-file PATH`
|
||||
* `--matrix-hs-token TOKEN`
|
||||
* `--matrix-hs-token-file PATH`
|
||||
* `--matrix-server-name NAME`
|
||||
* `--matrix-room-id ROOM`
|
||||
* `--container` / `--no-container`
|
||||
* `--secrets-dir PATH`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
* `POTATOMESH_CONFIG`
|
||||
* `POTATOMESH_BASE_URL`
|
||||
* `POTATOMESH_POLL_INTERVAL_SECS`
|
||||
* `MATRIX_HOMESERVER`
|
||||
* `MATRIX_AS_TOKEN`
|
||||
* `MATRIX_AS_TOKEN_FILE`
|
||||
* `MATRIX_HS_TOKEN`
|
||||
* `MATRIX_HS_TOKEN_FILE`
|
||||
* `MATRIX_SERVER_NAME`
|
||||
* `MATRIX_ROOM_ID`
|
||||
* `STATE_FILE`
|
||||
* `POTATOMESH_CONTAINER`
|
||||
* `POTATOMESH_SECRETS_DIR`
|
||||
|
||||
### Secret Files
|
||||
|
||||
If you supply `*_FILE` values, the bridge reads the secret contents and trims whitespace. When running inside a container, the bridge also checks the default secrets directory (default: `/run/secrets`) for:
|
||||
|
||||
* `matrix_as_token`
|
||||
* `matrix_hs_token`
|
||||
|
||||
### Container Defaults
|
||||
|
||||
Container detection checks `POTATOMESH_CONTAINER`, `CONTAINER`, and `/proc/1/cgroup`. When detected (or forced with `--container`), defaults shift to:
|
||||
|
||||
* Config path: `/app/Config.toml`
|
||||
* State file: `/app/bridge_state.json`
|
||||
* Secrets dir: `/run/secrets`
|
||||
* Poll interval: 15 seconds (if not otherwise configured)
|
||||
|
||||
Set `POTATOMESH_CONTAINER=0` or `--no-container` to opt out of container defaults.
|
||||
|
||||
### PotatoMesh API
|
||||
|
||||
The bridge assumes:
|
||||
@@ -186,7 +246,7 @@ Build the container from the repo root with the included `matrix/Dockerfile`:
|
||||
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
|
||||
```
|
||||
|
||||
Provide your config at `/app/Config.toml` and persist the bridge state file by mounting volumes. Minimal example:
|
||||
Provide your config at `/app/Config.toml` (or use CLI/env/secret overrides) and persist the bridge state file by mounting volumes. Minimal example:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
@@ -206,7 +266,7 @@ docker run --rm \
|
||||
potatomesh-matrix-bridge
|
||||
```
|
||||
|
||||
The image ships `Config.example.toml` for reference, but the bridge will exit if `/app/Config.toml` is not provided.
|
||||
The image ships `Config.example.toml` for reference. If `/app/Config.toml` is absent, set the required values via environment variables, CLI flags, or secrets instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -244,7 +304,7 @@ Delete `bridge_state.json` if you want it to replay all currently available mess
|
||||
|
||||
## Development
|
||||
|
||||
Run tests (currently mostly compile checks, no real tests yet):
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Default to container-aware configuration paths unless explicitly overridden.
|
||||
: "${POTATOMESH_CONTAINER:=1}"
|
||||
: "${POTATOMESH_SECRETS_DIR:=/run/secrets}"
|
||||
|
||||
export POTATOMESH_CONTAINER
|
||||
export POTATOMESH_SECRETS_DIR
|
||||
|
||||
# Default state file path from Config.toml unless overridden.
|
||||
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
|
||||
STATE_DIR="$(dirname "$STATE_FILE")"
|
||||
|
||||
105
matrix/src/cli.rs
Normal file
105
matrix/src/cli.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use clap::{ArgAction, Parser};
|
||||
|
||||
#[cfg(not(test))]
|
||||
use crate::config::{ConfigInputs, ConfigOverrides};
|
||||
|
||||
/// CLI arguments for the Matrix bridge.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "potatomesh-matrix-bridge",
|
||||
version,
|
||||
about = "PotatoMesh Matrix bridge"
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Path to the configuration TOML file.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<String>,
|
||||
/// Path to the bridge state file.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub state_file: Option<String>,
|
||||
/// PotatoMesh base URL.
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub potatomesh_base_url: Option<String>,
|
||||
/// Poll interval in seconds.
|
||||
#[arg(long, value_name = "SECS")]
|
||||
pub potatomesh_poll_interval_secs: Option<u64>,
|
||||
/// Matrix homeserver base URL.
|
||||
#[arg(long, value_name = "URL")]
|
||||
pub matrix_homeserver: Option<String>,
|
||||
/// Matrix appservice access token.
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
pub matrix_as_token: Option<String>,
|
||||
/// Path to a secret file containing the Matrix appservice access token.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub matrix_as_token_file: Option<String>,
|
||||
/// Matrix homeserver token for inbound appservice requests.
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
pub matrix_hs_token: Option<String>,
|
||||
/// Path to a secret file containing the Matrix homeserver token.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub matrix_hs_token_file: Option<String>,
|
||||
/// Matrix server name (domain).
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub matrix_server_name: Option<String>,
|
||||
/// Matrix room id to forward into.
|
||||
#[arg(long, value_name = "ROOM")]
|
||||
pub matrix_room_id: Option<String>,
|
||||
/// Force container defaults (overrides detection).
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub container: bool,
|
||||
/// Disable container defaults (overrides detection).
|
||||
#[arg(long, action = ArgAction::SetTrue)]
|
||||
pub no_container: bool,
|
||||
/// Directory to search for default secret files.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub secrets_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Convert CLI args into configuration inputs.
|
||||
#[cfg(not(test))]
|
||||
pub fn to_inputs(&self) -> ConfigInputs {
|
||||
ConfigInputs {
|
||||
config_path: self.config.clone(),
|
||||
secrets_dir: self.secrets_dir.clone(),
|
||||
container_override: resolve_container_override(self.container, self.no_container),
|
||||
container_hint: None,
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: self.potatomesh_base_url.clone(),
|
||||
potatomesh_poll_interval_secs: self.potatomesh_poll_interval_secs,
|
||||
matrix_homeserver: self.matrix_homeserver.clone(),
|
||||
matrix_as_token: self.matrix_as_token.clone(),
|
||||
matrix_as_token_file: self.matrix_as_token_file.clone(),
|
||||
matrix_hs_token: self.matrix_hs_token.clone(),
|
||||
matrix_hs_token_file: self.matrix_hs_token_file.clone(),
|
||||
matrix_server_name: self.matrix_server_name.clone(),
|
||||
matrix_room_id: self.matrix_room_id.clone(),
|
||||
state_file: self.state_file.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve container override flags into an optional boolean.
|
||||
#[cfg(not(test))]
|
||||
fn resolve_container_override(container: bool, no_container: bool) -> Option<bool> {
|
||||
match (container, no_container) {
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,21 @@
|
||||
use serde::Deserialize;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "Config.toml";
|
||||
const CONTAINER_CONFIG_PATH: &str = "/app/Config.toml";
|
||||
const DEFAULT_STATE_FILE: &str = "bridge_state.json";
|
||||
const CONTAINER_STATE_FILE: &str = "/app/bridge_state.json";
|
||||
const DEFAULT_SECRETS_DIR: &str = "/run/secrets";
|
||||
const CONTAINER_POLL_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
/// PotatoMesh API settings.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct PotatomeshConfig {
|
||||
pub base_url: String,
|
||||
pub poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Matrix appservice settings for the bridge.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MatrixConfig {
|
||||
pub homeserver: String,
|
||||
@@ -30,11 +39,13 @@ pub struct MatrixConfig {
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
/// State file configuration for the bridge.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct StateConfig {
|
||||
pub state_file: String,
|
||||
}
|
||||
|
||||
/// Full configuration loaded for the bridge runtime.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub potatomesh: PotatomeshConfig,
|
||||
@@ -42,19 +53,447 @@ pub struct Config {
|
||||
pub state: StateConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct PartialPotatomeshConfig {
|
||||
#[serde(default)]
|
||||
base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
poll_interval_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct PartialMatrixConfig {
|
||||
#[serde(default)]
|
||||
homeserver: Option<String>,
|
||||
#[serde(default)]
|
||||
as_token: Option<String>,
|
||||
#[serde(default)]
|
||||
hs_token: Option<String>,
|
||||
#[serde(default)]
|
||||
server_name: Option<String>,
|
||||
#[serde(default)]
|
||||
room_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct PartialStateConfig {
|
||||
#[serde(default)]
|
||||
state_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
struct PartialConfig {
|
||||
#[serde(default)]
|
||||
potatomesh: PartialPotatomeshConfig,
|
||||
#[serde(default)]
|
||||
matrix: PartialMatrixConfig,
|
||||
#[serde(default)]
|
||||
state: PartialStateConfig,
|
||||
}
|
||||
|
||||
/// Overwrite an optional value when the incoming value is present.
|
||||
fn merge_option<T>(target: &mut Option<T>, incoming: Option<T>) {
|
||||
if incoming.is_some() {
|
||||
*target = incoming;
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI or environment overrides for configuration fields.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConfigOverrides {
|
||||
pub potatomesh_base_url: Option<String>,
|
||||
pub potatomesh_poll_interval_secs: Option<u64>,
|
||||
pub matrix_homeserver: Option<String>,
|
||||
pub matrix_as_token: Option<String>,
|
||||
pub matrix_as_token_file: Option<String>,
|
||||
pub matrix_hs_token: Option<String>,
|
||||
pub matrix_hs_token_file: Option<String>,
|
||||
pub matrix_server_name: Option<String>,
|
||||
pub matrix_room_id: Option<String>,
|
||||
pub state_file: Option<String>,
|
||||
}
|
||||
|
||||
impl ConfigOverrides {
|
||||
fn apply_non_token_overrides(&self, cfg: &mut PartialConfig) {
|
||||
merge_option(
|
||||
&mut cfg.potatomesh.base_url,
|
||||
self.potatomesh_base_url.clone(),
|
||||
);
|
||||
merge_option(
|
||||
&mut cfg.potatomesh.poll_interval_secs,
|
||||
self.potatomesh_poll_interval_secs,
|
||||
);
|
||||
merge_option(&mut cfg.matrix.homeserver, self.matrix_homeserver.clone());
|
||||
merge_option(&mut cfg.matrix.server_name, self.matrix_server_name.clone());
|
||||
merge_option(&mut cfg.matrix.room_id, self.matrix_room_id.clone());
|
||||
merge_option(&mut cfg.state.state_file, self.state_file.clone());
|
||||
}
|
||||
|
||||
fn merge(self, higher: ConfigOverrides) -> ConfigOverrides {
|
||||
let matrix_as_token = if higher.matrix_as_token_file.is_some() {
|
||||
higher.matrix_as_token
|
||||
} else {
|
||||
higher.matrix_as_token.or(self.matrix_as_token)
|
||||
};
|
||||
let matrix_hs_token = if higher.matrix_hs_token_file.is_some() {
|
||||
higher.matrix_hs_token
|
||||
} else {
|
||||
higher.matrix_hs_token.or(self.matrix_hs_token)
|
||||
};
|
||||
ConfigOverrides {
|
||||
potatomesh_base_url: higher.potatomesh_base_url.or(self.potatomesh_base_url),
|
||||
potatomesh_poll_interval_secs: higher
|
||||
.potatomesh_poll_interval_secs
|
||||
.or(self.potatomesh_poll_interval_secs),
|
||||
matrix_homeserver: higher.matrix_homeserver.or(self.matrix_homeserver),
|
||||
matrix_as_token,
|
||||
matrix_as_token_file: higher.matrix_as_token_file.or(self.matrix_as_token_file),
|
||||
matrix_hs_token,
|
||||
matrix_hs_token_file: higher.matrix_hs_token_file.or(self.matrix_hs_token_file),
|
||||
matrix_server_name: higher.matrix_server_name.or(self.matrix_server_name),
|
||||
matrix_room_id: higher.matrix_room_id.or(self.matrix_room_id),
|
||||
state_file: higher.state_file.or(self.state_file),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs gathered from CLI flags or environment variables.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ConfigInputs {
|
||||
pub config_path: Option<String>,
|
||||
pub secrets_dir: Option<String>,
|
||||
pub container_override: Option<bool>,
|
||||
pub container_hint: Option<String>,
|
||||
pub overrides: ConfigOverrides,
|
||||
}
|
||||
|
||||
impl ConfigInputs {
|
||||
/// Merge two input sets, preferring values from `higher`.
|
||||
pub fn merge(self, higher: ConfigInputs) -> ConfigInputs {
|
||||
ConfigInputs {
|
||||
config_path: higher.config_path.or(self.config_path),
|
||||
secrets_dir: higher.secrets_dir.or(self.secrets_dir),
|
||||
container_override: higher.container_override.or(self.container_override),
|
||||
container_hint: higher.container_hint.or(self.container_hint),
|
||||
overrides: self.overrides.merge(higher.overrides),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration inputs from the process environment.
|
||||
#[cfg(not(test))]
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let overrides = ConfigOverrides {
|
||||
potatomesh_base_url: env_var("POTATOMESH_BASE_URL"),
|
||||
potatomesh_poll_interval_secs: parse_u64_env("POTATOMESH_POLL_INTERVAL_SECS")?,
|
||||
matrix_homeserver: env_var("MATRIX_HOMESERVER"),
|
||||
matrix_as_token: env_var("MATRIX_AS_TOKEN"),
|
||||
matrix_as_token_file: env_var("MATRIX_AS_TOKEN_FILE"),
|
||||
matrix_hs_token: env_var("MATRIX_HS_TOKEN"),
|
||||
matrix_hs_token_file: env_var("MATRIX_HS_TOKEN_FILE"),
|
||||
matrix_server_name: env_var("MATRIX_SERVER_NAME"),
|
||||
matrix_room_id: env_var("MATRIX_ROOM_ID"),
|
||||
state_file: env_var("STATE_FILE"),
|
||||
};
|
||||
Ok(ConfigInputs {
|
||||
config_path: env_var("POTATOMESH_CONFIG"),
|
||||
secrets_dir: env_var("POTATOMESH_SECRETS_DIR"),
|
||||
container_override: parse_bool_env("POTATOMESH_CONTAINER")?,
|
||||
container_hint: env_var("CONTAINER"),
|
||||
overrides,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load a full Config from a TOML file.
|
||||
#[cfg(test)]
|
||||
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let cfg = toml::from_str(&contents)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_default_path() -> anyhow::Result<Self> {
|
||||
let path = "Config.toml";
|
||||
if !Path::new(path).exists() {
|
||||
anyhow::bail!("Config file {path} not found");
|
||||
/// Load a Config by merging CLI/env overrides with an optional TOML file.
|
||||
#[cfg(not(test))]
|
||||
pub fn load(cli_inputs: ConfigInputs) -> anyhow::Result<Config> {
|
||||
let env_inputs = ConfigInputs::from_env()?;
|
||||
let cgroup_hint = read_cgroup();
|
||||
load_from_sources(cli_inputs, env_inputs, cgroup_hint.as_deref())
|
||||
}
|
||||
|
||||
/// Load configuration by merging CLI/env inputs and an optional config file.
|
||||
fn load_from_sources(
|
||||
cli_inputs: ConfigInputs,
|
||||
env_inputs: ConfigInputs,
|
||||
cgroup_hint: Option<&str>,
|
||||
) -> anyhow::Result<Config> {
|
||||
let merged_inputs = env_inputs.merge(cli_inputs);
|
||||
let container = detect_container(
|
||||
merged_inputs.container_override,
|
||||
merged_inputs.container_hint.as_deref(),
|
||||
cgroup_hint,
|
||||
);
|
||||
let defaults = default_paths(container);
|
||||
|
||||
let base_cfg = resolve_base_config(&merged_inputs, &defaults)?;
|
||||
let mut cfg = base_cfg.unwrap_or_default();
|
||||
merged_inputs.overrides.apply_non_token_overrides(&mut cfg);
|
||||
|
||||
let secrets_dir = resolve_secrets_dir(&merged_inputs, container, &defaults);
|
||||
let as_token = resolve_token(
|
||||
cfg.matrix.as_token.clone(),
|
||||
merged_inputs.overrides.matrix_as_token.clone(),
|
||||
merged_inputs.overrides.matrix_as_token_file.as_deref(),
|
||||
secrets_dir.as_deref(),
|
||||
"matrix_as_token",
|
||||
)?;
|
||||
let hs_token = resolve_token(
|
||||
cfg.matrix.hs_token.clone(),
|
||||
merged_inputs.overrides.matrix_hs_token.clone(),
|
||||
merged_inputs.overrides.matrix_hs_token_file.as_deref(),
|
||||
secrets_dir.as_deref(),
|
||||
"matrix_hs_token",
|
||||
)?;
|
||||
|
||||
if cfg.potatomesh.poll_interval_secs.is_none() && container {
|
||||
cfg.potatomesh.poll_interval_secs = Some(defaults.poll_interval_secs);
|
||||
}
|
||||
Self::load_from_file(path)
|
||||
|
||||
if cfg.state.state_file.is_none() {
|
||||
cfg.state.state_file = Some(defaults.state_file);
|
||||
}
|
||||
|
||||
let missing = collect_missing_fields(&cfg, &as_token, &hs_token);
|
||||
if !missing.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Missing required configuration values: {}",
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
potatomesh: PotatomeshConfig {
|
||||
base_url: cfg.potatomesh.base_url.unwrap(),
|
||||
poll_interval_secs: cfg.potatomesh.poll_interval_secs.unwrap(),
|
||||
},
|
||||
matrix: MatrixConfig {
|
||||
homeserver: cfg.matrix.homeserver.unwrap(),
|
||||
as_token: as_token.unwrap(),
|
||||
hs_token: hs_token.unwrap(),
|
||||
server_name: cfg.matrix.server_name.unwrap(),
|
||||
room_id: cfg.matrix.room_id.unwrap(),
|
||||
},
|
||||
state: StateConfig {
|
||||
state_file: cfg.state.state_file.unwrap(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Collect the missing required field identifiers for error reporting.
|
||||
fn collect_missing_fields(
|
||||
cfg: &PartialConfig,
|
||||
as_token: &Option<String>,
|
||||
hs_token: &Option<String>,
|
||||
) -> Vec<&'static str> {
|
||||
let mut missing = Vec::new();
|
||||
if cfg.potatomesh.base_url.is_none() {
|
||||
missing.push("potatomesh.base_url");
|
||||
}
|
||||
if cfg.potatomesh.poll_interval_secs.is_none() {
|
||||
missing.push("potatomesh.poll_interval_secs");
|
||||
}
|
||||
if cfg.matrix.homeserver.is_none() {
|
||||
missing.push("matrix.homeserver");
|
||||
}
|
||||
if as_token.is_none() {
|
||||
missing.push("matrix.as_token");
|
||||
}
|
||||
if hs_token.is_none() {
|
||||
missing.push("matrix.hs_token");
|
||||
}
|
||||
if cfg.matrix.server_name.is_none() {
|
||||
missing.push("matrix.server_name");
|
||||
}
|
||||
if cfg.matrix.room_id.is_none() {
|
||||
missing.push("matrix.room_id");
|
||||
}
|
||||
if cfg.state.state_file.is_none() {
|
||||
missing.push("state.state_file");
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// Resolve the base TOML config file, honoring explicit config paths.
|
||||
fn resolve_base_config(
|
||||
inputs: &ConfigInputs,
|
||||
defaults: &DefaultPaths,
|
||||
) -> anyhow::Result<Option<PartialConfig>> {
|
||||
if let Some(path) = &inputs.config_path {
|
||||
return Ok(Some(load_partial_from_file(path)?));
|
||||
}
|
||||
let container_path = Path::new(&defaults.config_path);
|
||||
if container_path.exists() {
|
||||
return Ok(Some(load_partial_from_file(&defaults.config_path)?));
|
||||
}
|
||||
let host_path = Path::new(DEFAULT_CONFIG_PATH);
|
||||
if host_path.exists() {
|
||||
return Ok(Some(load_partial_from_file(DEFAULT_CONFIG_PATH)?));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Decide which secrets directory to use based on inputs and defaults.
|
||||
fn resolve_secrets_dir(
|
||||
inputs: &ConfigInputs,
|
||||
container: bool,
|
||||
defaults: &DefaultPaths,
|
||||
) -> Option<String> {
|
||||
if let Some(explicit) = inputs.secrets_dir.clone() {
|
||||
return Some(explicit);
|
||||
}
|
||||
if container {
|
||||
return Some(defaults.secrets_dir.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a token value from explicit values, secret files, or config file values.
|
||||
fn resolve_token(
|
||||
base_value: Option<String>,
|
||||
explicit_value: Option<String>,
|
||||
explicit_file: Option<&str>,
|
||||
secrets_dir: Option<&str>,
|
||||
default_secret_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(value) = explicit_value {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(path) = explicit_file {
|
||||
return Ok(Some(read_secret_file(path)?));
|
||||
}
|
||||
if let Some(dir) = secrets_dir {
|
||||
let default_path = Path::new(dir).join(default_secret_name);
|
||||
if default_path.exists() {
|
||||
return Ok(Some(read_secret_file(
|
||||
default_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid secret file path"))?,
|
||||
)?));
|
||||
}
|
||||
}
|
||||
Ok(base_value)
|
||||
}
|
||||
|
||||
/// Read and trim a secret file from disk.
|
||||
fn read_secret_file(path: &str) -> anyhow::Result<String> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let trimmed = contents.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Secret file {path} is empty");
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
/// Load a partial config from a TOML file.
|
||||
fn load_partial_from_file(path: &str) -> anyhow::Result<PartialConfig> {
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let cfg = toml::from_str(&contents)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Compute default paths and intervals based on container mode.
|
||||
fn default_paths(container: bool) -> DefaultPaths {
|
||||
if container {
|
||||
DefaultPaths {
|
||||
config_path: CONTAINER_CONFIG_PATH.to_string(),
|
||||
state_file: CONTAINER_STATE_FILE.to_string(),
|
||||
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
|
||||
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
|
||||
}
|
||||
} else {
|
||||
DefaultPaths {
|
||||
config_path: DEFAULT_CONFIG_PATH.to_string(),
|
||||
state_file: DEFAULT_STATE_FILE.to_string(),
|
||||
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
|
||||
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DefaultPaths {
|
||||
config_path: String,
|
||||
state_file: String,
|
||||
secrets_dir: String,
|
||||
poll_interval_secs: u64,
|
||||
}
|
||||
|
||||
/// Detect whether the bridge is running inside a container.
|
||||
fn detect_container(
|
||||
override_value: Option<bool>,
|
||||
env_hint: Option<&str>,
|
||||
cgroup_hint: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(value) = override_value {
|
||||
return value;
|
||||
}
|
||||
if let Some(hint) = env_hint {
|
||||
if !hint.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(cgroup) = cgroup_hint {
|
||||
let haystack = cgroup.to_ascii_lowercase();
|
||||
return haystack.contains("docker")
|
||||
|| haystack.contains("kubepods")
|
||||
|| haystack.contains("containerd")
|
||||
|| haystack.contains("podman");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the primary cgroup file for container detection.
|
||||
#[cfg(not(test))]
|
||||
fn read_cgroup() -> Option<String> {
|
||||
fs::read_to_string("/proc/1/cgroup").ok()
|
||||
}
|
||||
|
||||
/// Read and trim an environment variable value.
|
||||
#[cfg(not(test))]
|
||||
fn env_var(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Parse a u64 environment variable value.
|
||||
#[cfg(not(test))]
|
||||
fn parse_u64_env(key: &str) -> anyhow::Result<Option<u64>> {
|
||||
match env_var(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => value
|
||||
.parse::<u64>()
|
||||
.map(Some)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid {key} value: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a boolean environment variable value.
|
||||
#[cfg(not(test))]
|
||||
fn parse_bool_env(key: &str) -> anyhow::Result<Option<bool>> {
|
||||
match env_var(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => parse_bool_value(key, &value).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a boolean string with standard truthy/falsy values.
|
||||
#[cfg(not(test))]
|
||||
fn parse_bool_value(key: &str, value: &str) -> anyhow::Result<bool> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
_ => anyhow::bail!("Invalid {key} value: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +502,43 @@ mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct CwdGuard {
|
||||
original: PathBuf,
|
||||
}
|
||||
|
||||
impl CwdGuard {
|
||||
/// Switch to the provided path and restore the original cwd on drop.
|
||||
fn enter(path: &Path) -> Self {
|
||||
let original = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||
std::env::set_current_dir(path).unwrap();
|
||||
Self { original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CwdGuard {
|
||||
fn drop(&mut self) {
|
||||
if std::env::set_current_dir(&self.original).is_err() {
|
||||
let _ = std::env::set_current_dir("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_overrides() -> ConfigOverrides {
|
||||
ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
|
||||
potatomesh_poll_interval_secs: Some(10),
|
||||
matrix_homeserver: Some("https://matrix.example.org".to_string()),
|
||||
matrix_as_token: Some("AS_TOKEN".to_string()),
|
||||
matrix_hs_token: Some("HS_TOKEN".to_string()),
|
||||
matrix_server_name: Some("example.org".to_string()),
|
||||
matrix_room_id: Some("!roomid:example.org".to_string()),
|
||||
state_file: Some("bridge_state.json".to_string()),
|
||||
matrix_as_token_file: None,
|
||||
matrix_hs_token_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_config_from_toml_str() {
|
||||
@@ -125,21 +601,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn from_default_path_not_found() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
std::env::set_current_dir(tmp_dir.path()).unwrap();
|
||||
let result = Config::from_default_path();
|
||||
assert!(result.is_err());
|
||||
fn detect_container_prefers_override() {
|
||||
assert!(detect_container(Some(true), None, None));
|
||||
assert!(!detect_container(
|
||||
Some(false),
|
||||
Some("docker"),
|
||||
Some("docker")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn from_default_path_found() {
|
||||
fn detect_container_from_hint_or_cgroup() {
|
||||
assert!(detect_container(None, Some("docker"), None));
|
||||
assert!(detect_container(None, None, Some("kubepods")));
|
||||
assert!(!detect_container(None, None, Some("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_uses_cli_overrides_over_env() {
|
||||
let toml_str = r#"
|
||||
[potatomesh]
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
poll_interval_secs = 5
|
||||
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
@@ -151,12 +634,345 @@ mod tests {
|
||||
[state]
|
||||
state_file = "bridge_state.json"
|
||||
"#;
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = tmp_dir.path().join("Config.toml");
|
||||
let mut file = std::fs::File::create(file_path).unwrap();
|
||||
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(file, "{}", toml_str).unwrap();
|
||||
std::env::set_current_dir(tmp_dir.path()).unwrap();
|
||||
let result = Config::from_default_path();
|
||||
assert!(result.is_ok());
|
||||
|
||||
let env_inputs = ConfigInputs {
|
||||
config_path: Some(file.path().to_str().unwrap().to_string()),
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://env.example/".to_string()),
|
||||
..minimal_overrides()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
let cli_inputs = ConfigInputs {
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://cli.example/".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let cfg = load_from_sources(cli_inputs, env_inputs, None).unwrap();
|
||||
assert_eq!(cfg.potatomesh.base_url, "https://cli.example/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_uses_container_secret_defaults() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
let secrets_dir = tmp_dir.path();
|
||||
fs::write(secrets_dir.join("matrix_as_token"), "FROM_SECRET").unwrap();
|
||||
|
||||
let cli_inputs = ConfigInputs {
|
||||
secrets_dir: Some(secrets_dir.to_string_lossy().to_string()),
|
||||
container_override: Some(true),
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
|
||||
potatomesh_poll_interval_secs: Some(10),
|
||||
matrix_homeserver: Some("https://matrix.example.org".to_string()),
|
||||
matrix_hs_token: Some("HS_TOKEN".to_string()),
|
||||
matrix_server_name: Some("example.org".to_string()),
|
||||
matrix_room_id: Some("!roomid:example.org".to_string()),
|
||||
state_file: Some("bridge_state.json".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
|
||||
assert_eq!(cfg.matrix.as_token, "FROM_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_token_prefers_explicit_value() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let token_file = tmp_dir.path().join("token");
|
||||
fs::write(&token_file, "FROM_FILE").unwrap();
|
||||
|
||||
let resolved = resolve_token(
|
||||
Some("FROM_BASE".to_string()),
|
||||
Some("FROM_EXPLICIT".to_string()),
|
||||
Some(token_file.to_str().unwrap()),
|
||||
Some(tmp_dir.path().to_str().unwrap()),
|
||||
"matrix_as_token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved, Some("FROM_EXPLICIT".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_token_reads_explicit_file() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let token_file = tmp_dir.path().join("token");
|
||||
fs::write(&token_file, "FROM_FILE").unwrap();
|
||||
|
||||
let resolved = resolve_token(
|
||||
None,
|
||||
None,
|
||||
Some(token_file.to_str().unwrap()),
|
||||
None,
|
||||
"matrix_as_token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved, Some("FROM_FILE".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_token_reads_default_secret_file() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp_dir.path().join("matrix_hs_token"), "FROM_SECRET").unwrap();
|
||||
|
||||
let resolved = resolve_token(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(tmp_dir.path().to_str().unwrap()),
|
||||
"matrix_hs_token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved, Some("FROM_SECRET".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_token_errors_on_empty_secret_file() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let token_file = tmp_dir.path().join("token");
|
||||
fs::write(&token_file, " ").unwrap();
|
||||
|
||||
let result = resolve_token(
|
||||
None,
|
||||
None,
|
||||
Some(token_file.to_str().unwrap()),
|
||||
None,
|
||||
"matrix_as_token",
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secrets_dir_prefers_explicit() {
|
||||
let defaults = DefaultPaths {
|
||||
config_path: "Config.toml".to_string(),
|
||||
state_file: DEFAULT_STATE_FILE.to_string(),
|
||||
secrets_dir: "default".to_string(),
|
||||
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
|
||||
};
|
||||
let inputs = ConfigInputs {
|
||||
secrets_dir: Some("explicit".to_string()),
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let resolved = resolve_secrets_dir(&inputs, true, &defaults);
|
||||
assert_eq!(resolved, Some("explicit".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secrets_dir_container_default() {
|
||||
let defaults = DefaultPaths {
|
||||
config_path: "Config.toml".to_string(),
|
||||
state_file: DEFAULT_STATE_FILE.to_string(),
|
||||
secrets_dir: "default".to_string(),
|
||||
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
|
||||
};
|
||||
let inputs = ConfigInputs::default();
|
||||
|
||||
let resolved = resolve_secrets_dir(&inputs, true, &defaults);
|
||||
assert_eq!(resolved, Some("default".to_string()));
|
||||
assert_eq!(resolve_secrets_dir(&inputs, false, &defaults), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_base_config_prefers_explicit_path() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
let config_path = tmp_dir.path().join("explicit.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"[potatomesh]
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
as_token = "AS_TOKEN"
|
||||
hs_token = "HS_TOKEN"
|
||||
server_name = "example.org"
|
||||
room_id = "!roomid:example.org"
|
||||
[state]
|
||||
state_file = "bridge_state.json"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let defaults = default_paths(false);
|
||||
let inputs = ConfigInputs {
|
||||
config_path: Some(config_path.to_string_lossy().to_string()),
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let resolved = resolve_base_config(&inputs, &defaults).unwrap();
|
||||
assert!(resolved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_base_config_uses_container_path_when_present() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
let config_path = tmp_dir.path().join("container.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"[potatomesh]
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
as_token = "AS_TOKEN"
|
||||
hs_token = "HS_TOKEN"
|
||||
server_name = "example.org"
|
||||
room_id = "!roomid:example.org"
|
||||
[state]
|
||||
state_file = "bridge_state.json"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let defaults = DefaultPaths {
|
||||
config_path: config_path.to_string_lossy().to_string(),
|
||||
state_file: DEFAULT_STATE_FILE.to_string(),
|
||||
secrets_dir: DEFAULT_SECRETS_DIR.to_string(),
|
||||
poll_interval_secs: CONTAINER_POLL_INTERVAL_SECS,
|
||||
};
|
||||
|
||||
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
|
||||
assert!(resolved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_base_config_uses_host_path_when_present() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
fs::write(
|
||||
"Config.toml",
|
||||
r#"[potatomesh]
|
||||
base_url = "https://potatomesh.net/"
|
||||
poll_interval_secs = 10
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
as_token = "AS_TOKEN"
|
||||
hs_token = "HS_TOKEN"
|
||||
server_name = "example.org"
|
||||
room_id = "!roomid:example.org"
|
||||
[state]
|
||||
state_file = "bridge_state.json"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let defaults = default_paths(false);
|
||||
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
|
||||
assert!(resolved.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_base_config_returns_none_when_missing() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
let defaults = default_paths(false);
|
||||
let resolved = resolve_base_config(&ConfigInputs::default(), &defaults).unwrap();
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_prefers_cli_token_file_over_env_value() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
|
||||
let token_file = tmp_dir.path().join("as_token");
|
||||
fs::write(&token_file, "CLI_SECRET").unwrap();
|
||||
|
||||
let env_inputs = ConfigInputs {
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
|
||||
potatomesh_poll_interval_secs: Some(10),
|
||||
matrix_homeserver: Some("https://matrix.example.org".to_string()),
|
||||
matrix_as_token: Some("ENV_TOKEN".to_string()),
|
||||
matrix_hs_token: Some("HS_TOKEN".to_string()),
|
||||
matrix_server_name: Some("example.org".to_string()),
|
||||
matrix_room_id: Some("!roomid:example.org".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
let cli_inputs = ConfigInputs {
|
||||
overrides: ConfigOverrides {
|
||||
matrix_as_token_file: Some(token_file.to_string_lossy().to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let cfg = load_from_sources(cli_inputs, env_inputs, None).unwrap();
|
||||
assert_eq!(cfg.matrix.as_token, "CLI_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_uses_container_default_poll_interval() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
|
||||
let cli_inputs = ConfigInputs {
|
||||
container_override: Some(true),
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
|
||||
matrix_homeserver: Some("https://matrix.example.org".to_string()),
|
||||
matrix_as_token: Some("AS_TOKEN".to_string()),
|
||||
matrix_hs_token: Some("HS_TOKEN".to_string()),
|
||||
matrix_server_name: Some("example.org".to_string()),
|
||||
matrix_room_id: Some("!roomid:example.org".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
|
||||
assert_eq!(
|
||||
cfg.potatomesh.poll_interval_secs,
|
||||
CONTAINER_POLL_INTERVAL_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn load_uses_default_state_path_when_missing() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = CwdGuard::enter(tmp_dir.path());
|
||||
|
||||
let cli_inputs = ConfigInputs {
|
||||
overrides: ConfigOverrides {
|
||||
potatomesh_base_url: Some("https://potatomesh.net/".to_string()),
|
||||
potatomesh_poll_interval_secs: Some(10),
|
||||
matrix_homeserver: Some("https://matrix.example.org".to_string()),
|
||||
matrix_as_token: Some("AS_TOKEN".to_string()),
|
||||
matrix_hs_token: Some("HS_TOKEN".to_string()),
|
||||
matrix_server_name: Some("example.org".to_string()),
|
||||
matrix_room_id: Some("!roomid:example.org".to_string()),
|
||||
..ConfigOverrides::default()
|
||||
},
|
||||
..ConfigInputs::default()
|
||||
};
|
||||
|
||||
let cfg = load_from_sources(cli_inputs, ConfigInputs::default(), None).unwrap();
|
||||
assert_eq!(cfg.state.state_file, DEFAULT_STATE_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod matrix;
|
||||
mod matrix_server;
|
||||
@@ -20,9 +21,13 @@ mod potatomesh;
|
||||
use std::{fs, net::SocketAddr, path::Path};
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(not(test))]
|
||||
use clap::Parser;
|
||||
use tokio::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[cfg(not(test))]
|
||||
use crate::cli::Cli;
|
||||
#[cfg(not(test))]
|
||||
use crate::config::Config;
|
||||
use crate::matrix::MatrixAppserviceClient;
|
||||
@@ -207,7 +212,8 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = Config::from_default_path()?;
|
||||
let cli = Cli::parse();
|
||||
let cfg = config::load(cli.to_inputs())?;
|
||||
log_config(&cfg);
|
||||
|
||||
let http = reqwest::Client::builder().build()?;
|
||||
|
||||
Reference in New Issue
Block a user