Compare commits

...

3 Commits

Author SHA1 Message Date
l5y
f8660907e5 matrix: cover missing unit test vectors 2026-01-06 18:05:23 +01:00
l5y
96b2942065 matrix: fix tests 2026-01-06 17:56:20 +01:00
l5y
4f0410c7da matrix: add docker env boilerplate 2026-01-06 17:54:50 +01:00
7 changed files with 1087 additions and 5 deletions

127
matrix/Cargo.lock generated
View File

@@ -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"
@@ -85,6 +135,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"
@@ -317,6 +413,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"
@@ -572,6 +674,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.15"
@@ -721,6 +829,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"
@@ -817,6 +931,7 @@ name = "potatomesh-matrix-bridge"
version = "0.5.9"
dependencies = [
"anyhow",
"clap",
"mockito",
"reqwest",
"serde",
@@ -1309,6 +1424,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"
@@ -1681,6 +1802,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"

View File

@@ -27,6 +27,7 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
urlencoding = "2"
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
tempfile = "3"

View File

@@ -54,7 +54,9 @@ 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 TOML, CLI flags, and environment variables. The TOML
file is optional as long as every required setting is supplied via CLI/env/secret
overrides.
Example:
@@ -80,6 +82,66 @@ room_id = "!yourroomid:example.org"
state_file = "bridge_state.json"
````
### CLI Overrides
Run `potatomesh-matrix-bridge --help` for the full list. The most common flags:
- `--config` (or `--config-path`) to point at a TOML file
- `--state-file`
- `--potatomesh-base-url`
- `--potatomesh-poll-interval-secs`
- `--matrix-homeserver`
- `--matrix-as-token`
- `--matrix-server-name`
- `--matrix-room-id`
- `--container-defaults` / `--no-container-defaults`
### Environment Overrides
Environment variables override CLI and TOML values:
- `POTATOMESH_BASE_URL`
- `POTATOMESH_POLL_INTERVAL_SECS`
- `MATRIX_HOMESERVER`
- `MATRIX_AS_TOKEN`
- `MATRIX_SERVER_NAME`
- `MATRIX_ROOM_ID`
- `STATE_FILE`
- `POTATOMESH_CONFIG_PATH` (optional TOML path)
- `POTATOMESH_CONTAINER_DEFAULTS` (`1/0`, `true/false`)
- `POTATOMESH_SECRETS_DIR` (default secrets directory)
- `CONTAINER` (container detection hint)
### Docker Secrets
Every env var above supports a `*_FILE` companion (for example, `MATRIX_AS_TOKEN_FILE`).
When present, the bridge reads the file contents and uses them instead of the plain env var.
If `POTATOMESH_SECRETS_DIR` is set (or container defaults are enabled), the bridge also
checks for files named after the env vars (for example, `/run/secrets/MATRIX_AS_TOKEN`)
even when the `*_FILE` variable is not set.
### Precedence
From highest to lowest:
1. `*_FILE` secret values (explicit or default secrets directory)
2. Environment variables
3. CLI flags
4. TOML config
5. Built-in defaults
### Container Defaults
When container defaults are enabled (auto-detected or forced):
- Default config path: `/app/Config.toml`
- Default state file: `/app/bridge_state.json`
- Default secrets directory: `/run/secrets`
- Default poll interval: 120 seconds
Disable container defaults with `--no-container-defaults` or set
`POTATOMESH_CONTAINER_DEFAULTS=0`.
### PotatoMesh API
The bridge assumes:

View File

@@ -15,6 +15,11 @@
set -e
# Surface container detection for the bridge and set default secret directory.
export CONTAINER="${CONTAINER:-1}"
export POTATOMESH_CONTAINER_DEFAULTS="${POTATOMESH_CONTAINER_DEFAULTS:-1}"
export POTATOMESH_SECRETS_DIR="${POTATOMESH_SECRETS_DIR:-/run/secrets}"
# Default state file path from Config.toml unless overridden.
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
STATE_DIR="$(dirname "$STATE_FILE")"

159
matrix/src/cli.rs Normal file
View File

@@ -0,0 +1,159 @@
// 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::Parser;
use crate::config::{
BootstrapOverrides, ConfigOverrides, MatrixOverrides, PotatomeshOverrides, StateOverrides,
};
/// Command-line overrides for the Matrix bridge.
#[derive(Debug, Parser)]
#[command(name = "potatomesh-matrix-bridge", version)]
pub struct Cli {
/// TOML config path (optional, defaults to Config.toml or /app/Config.toml in containers).
#[arg(long = "config", alias = "config-path")]
pub config_path: Option<String>,
/// Override the state file path.
#[arg(long)]
pub state_file: Option<String>,
/// Override the PotatoMesh base URL.
#[arg(long)]
pub potatomesh_base_url: Option<String>,
/// Override the PotatoMesh poll interval in seconds.
#[arg(long)]
pub potatomesh_poll_interval_secs: Option<u64>,
/// Override the Matrix homeserver URL.
#[arg(long)]
pub matrix_homeserver: Option<String>,
/// Override the Matrix appservice access token.
#[arg(long)]
pub matrix_as_token: Option<String>,
/// Override the Matrix server name.
#[arg(long)]
pub matrix_server_name: Option<String>,
/// Override the Matrix room ID.
#[arg(long)]
pub matrix_room_id: Option<String>,
/// Force container defaults on even if container detection is false.
#[arg(long, conflicts_with = "no_container_defaults")]
pub container_defaults: bool,
/// Disable container defaults even if a container is detected.
#[arg(long, conflicts_with = "container_defaults")]
pub no_container_defaults: bool,
}
impl Cli {
/// Convert CLI flags to bootstrap overrides for config loading.
pub fn into_overrides(self) -> BootstrapOverrides {
let container_defaults = if self.container_defaults {
Some(true)
} else if self.no_container_defaults {
Some(false)
} else {
None
};
BootstrapOverrides {
config_path: self.config_path,
container_defaults,
values: ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: self.potatomesh_base_url,
poll_interval_secs: self.potatomesh_poll_interval_secs,
},
matrix: MatrixOverrides {
homeserver: self.matrix_homeserver,
as_token: self.matrix_as_token,
server_name: self.matrix_server_name,
room_id: self.matrix_room_id,
},
state: StateOverrides {
state_file: self.state_file,
},
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_overrides_map_to_config() {
let cli = Cli::parse_from([
"bridge",
"--config",
"/tmp/Config.toml",
"--state-file",
"/tmp/state.json",
"--potatomesh-base-url",
"https://potato.example/",
"--potatomesh-poll-interval-secs",
"15",
"--matrix-homeserver",
"https://matrix.example.org",
"--matrix-as-token",
"token",
"--matrix-server-name",
"example.org",
"--matrix-room-id",
"!room:example.org",
"--container-defaults",
]);
let overrides = cli.into_overrides();
assert_eq!(overrides.config_path.as_deref(), Some("/tmp/Config.toml"));
assert_eq!(overrides.container_defaults, Some(true));
assert_eq!(
overrides.values.potatomesh.base_url.as_deref(),
Some("https://potato.example/")
);
assert_eq!(overrides.values.potatomesh.poll_interval_secs, Some(15));
assert_eq!(
overrides.values.matrix.homeserver.as_deref(),
Some("https://matrix.example.org")
);
assert_eq!(overrides.values.matrix.as_token.as_deref(), Some("token"));
assert_eq!(
overrides.values.matrix.server_name.as_deref(),
Some("example.org")
);
assert_eq!(
overrides.values.matrix.room_id.as_deref(),
Some("!room:example.org")
);
assert_eq!(
overrides.values.state.state_file.as_deref(),
Some("/tmp/state.json")
);
}
#[test]
fn cli_can_disable_container_defaults() {
let cli = Cli::parse_from(["bridge", "--no-container-defaults"]);
let overrides = cli.into_overrides();
assert_eq!(overrides.container_defaults, Some(false));
}
}

View File

@@ -13,14 +13,40 @@
// limitations under the License.
use serde::Deserialize;
use std::{fs, path::Path};
use std::{
env, fs,
path::{Path, PathBuf},
};
const DEFAULT_CONFIG_PATH: &str = "Config.toml";
const DEFAULT_CONTAINER_CONFIG_PATH: &str = "/app/Config.toml";
const DEFAULT_STATE_FILE: &str = "bridge_state.json";
const DEFAULT_CONTAINER_STATE_FILE: &str = "/app/bridge_state.json";
const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
const DEFAULT_CONTAINER_POLL_INTERVAL_SECS: u64 = 120;
const DEFAULT_SECRETS_DIR: &str = "/run/secrets";
const ENV_CONTAINER: &str = "CONTAINER";
const ENV_CONTAINER_DEFAULTS: &str = "POTATOMESH_CONTAINER_DEFAULTS";
const ENV_CONFIG_PATH: &str = "POTATOMESH_CONFIG_PATH";
const ENV_SECRETS_DIR: &str = "POTATOMESH_SECRETS_DIR";
const ENV_POTATOMESH_BASE_URL: &str = "POTATOMESH_BASE_URL";
const ENV_POTATOMESH_POLL_INTERVAL: &str = "POTATOMESH_POLL_INTERVAL_SECS";
const ENV_MATRIX_HOMESERVER: &str = "MATRIX_HOMESERVER";
const ENV_MATRIX_AS_TOKEN: &str = "MATRIX_AS_TOKEN";
const ENV_MATRIX_SERVER_NAME: &str = "MATRIX_SERVER_NAME";
const ENV_MATRIX_ROOM_ID: &str = "MATRIX_ROOM_ID";
const ENV_STATE_FILE: &str = "STATE_FILE";
/// Configuration for the PotatoMesh API access.
#[derive(Debug, Deserialize, Clone)]
pub struct PotatomeshConfig {
pub base_url: String,
pub poll_interval_secs: u64,
}
/// Configuration for Matrix appservice access.
#[derive(Debug, Deserialize, Clone)]
pub struct MatrixConfig {
pub homeserver: String,
@@ -29,11 +55,13 @@ pub struct MatrixConfig {
pub room_id: String,
}
/// Configuration for persisted bridge state.
#[derive(Debug, Deserialize, Clone)]
pub struct StateConfig {
pub state_file: String,
}
/// Complete bridge configuration, merged from file and overrides.
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub potatomesh: PotatomeshConfig,
@@ -41,20 +69,466 @@ pub struct Config {
pub state: StateConfig,
}
/// Optional configuration overrides for a single section.
#[derive(Debug, Clone, Default)]
pub struct PotatomeshOverrides {
pub base_url: Option<String>,
pub poll_interval_secs: Option<u64>,
}
/// Optional Matrix overrides.
#[derive(Debug, Clone, Default)]
pub struct MatrixOverrides {
pub homeserver: Option<String>,
pub as_token: Option<String>,
pub server_name: Option<String>,
pub room_id: Option<String>,
}
/// Optional state overrides.
#[derive(Debug, Clone, Default)]
pub struct StateOverrides {
pub state_file: Option<String>,
}
/// Override bundle merged from TOML, CLI, env, and secret files.
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub potatomesh: PotatomeshOverrides,
pub matrix: MatrixOverrides,
pub state: StateOverrides,
}
/// Runtime context discovered while bootstrapping configuration.
#[derive(Debug, Clone)]
pub struct RuntimeContext {
pub in_container: bool,
pub container_defaults: bool,
pub config_path: String,
pub secrets_dir: Option<PathBuf>,
}
/// Bootstrapped configuration and runtime context.
#[derive(Debug, Clone)]
pub struct ConfigBootstrap {
pub config: Config,
pub context: RuntimeContext,
}
/// CLI-provided override bundle with container defaults toggles.
#[derive(Debug, Clone, Default)]
pub struct BootstrapOverrides {
pub config_path: Option<String>,
pub container_defaults: Option<bool>,
pub values: ConfigOverrides,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PotatomeshFileOverrides {
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
poll_interval_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct MatrixFileOverrides {
#[serde(default)]
homeserver: Option<String>,
#[serde(default)]
as_token: Option<String>,
#[serde(default)]
server_name: Option<String>,
#[serde(default)]
room_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct StateFileOverrides {
#[serde(default)]
state_file: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct ConfigFileOverrides {
#[serde(default)]
potatomesh: PotatomeshFileOverrides,
#[serde(default)]
matrix: MatrixFileOverrides,
#[serde(default)]
state: StateFileOverrides,
}
impl ConfigOverrides {
/// Merge another override set, replacing only fields present in `other`.
pub fn merge(&mut self, other: ConfigOverrides) {
self.potatomesh.merge(other.potatomesh);
self.matrix.merge(other.matrix);
self.state.merge(other.state);
}
}
impl PotatomeshOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: PotatomeshOverrides) {
if other.base_url.is_some() {
self.base_url = other.base_url;
}
if other.poll_interval_secs.is_some() {
self.poll_interval_secs = other.poll_interval_secs;
}
}
}
impl MatrixOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: MatrixOverrides) {
if other.homeserver.is_some() {
self.homeserver = other.homeserver;
}
if other.as_token.is_some() {
self.as_token = other.as_token;
}
if other.server_name.is_some() {
self.server_name = other.server_name;
}
if other.room_id.is_some() {
self.room_id = other.room_id;
}
}
}
impl StateOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: StateOverrides) {
if other.state_file.is_some() {
self.state_file = other.state_file;
}
}
}
impl From<ConfigFileOverrides> for ConfigOverrides {
fn from(value: ConfigFileOverrides) -> Self {
Self {
potatomesh: PotatomeshOverrides {
base_url: value.potatomesh.base_url,
poll_interval_secs: value.potatomesh.poll_interval_secs,
},
matrix: MatrixOverrides {
homeserver: value.matrix.homeserver,
as_token: value.matrix.as_token,
server_name: value.matrix.server_name,
room_id: value.matrix.room_id,
},
state: StateOverrides {
state_file: value.state.state_file,
},
}
}
}
/// Detect container context from env or cgroup hints.
fn detect_container() -> bool {
let env_value = env::var(ENV_CONTAINER).ok();
let cgroup_contents = fs::read_to_string("/proc/1/cgroup").ok();
detect_container_from(env_value.as_deref(), cgroup_contents.as_deref())
}
/// Detect container context from provided inputs (used for testing).
fn detect_container_from(env_value: Option<&str>, cgroup_contents: Option<&str>) -> bool {
if let Some(value) = env_value.map(str::trim).filter(|v| !v.is_empty()) {
let normalized = value.to_ascii_lowercase();
return normalized != "0" && normalized != "false";
}
if let Some(cgroup) = cgroup_contents {
let haystack = cgroup.to_lowercase();
return haystack.contains("docker")
|| haystack.contains("containerd")
|| haystack.contains("kubepods")
|| haystack.contains("podman")
|| haystack.contains("lxc");
}
false
}
/// Read an environment variable, trimming whitespace and ignoring empty values.
fn read_env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Parse a boolean env var, accepting common truthy/falsey values.
fn read_env_bool(key: &str) -> anyhow::Result<Option<bool>> {
let raw = match read_env_string(key) {
Some(value) => value,
None => return Ok(None),
};
let normalized = raw.to_ascii_lowercase();
let parsed = match normalized.as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => {
return Err(anyhow::anyhow!(
"Invalid boolean value for {}: {}",
key,
raw
))
}
};
Ok(Some(parsed))
}
/// Parse a u64 env var with context in error messages.
fn read_env_u64(key: &str) -> anyhow::Result<Option<u64>> {
let raw = match read_env_string(key) {
Some(value) => value,
None => return Ok(None),
};
let parsed = raw
.parse::<u64>()
.map_err(|err| anyhow::anyhow!("Invalid integer value for {}: {} ({})", key, raw, err))?;
Ok(Some(parsed))
}
/// Load a secret value from a file path and trim trailing whitespace.
fn read_secret_file(path: &Path) -> anyhow::Result<String> {
let raw = fs::read_to_string(path)?;
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("Secret file {} is empty", path.display());
}
Ok(trimmed)
}
/// Resolve a *_FILE env var or default secrets file.
fn read_secret_value(var_name: &str, secrets_dir: Option<&Path>) -> anyhow::Result<Option<String>> {
let file_env = format!("{}_FILE", var_name);
if let Some(path) = read_env_string(&file_env) {
return Ok(Some(read_secret_file(Path::new(&path))?));
}
if let Some(dir) = secrets_dir {
let path = dir.join(var_name);
if path.exists() {
return Ok(Some(read_secret_file(&path)?));
}
}
Ok(None)
}
/// Load a config file if it exists, returning overrides for present fields.
fn load_optional_config(path: &str) -> anyhow::Result<Option<ConfigOverrides>> {
if !Path::new(path).exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
let cfg: ConfigFileOverrides = toml::from_str(&contents)?;
Ok(Some(cfg.into()))
}
/// Build overrides from environment variables (non-secret values).
fn env_overrides() -> anyhow::Result<ConfigOverrides> {
Ok(ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: read_env_string(ENV_POTATOMESH_BASE_URL),
poll_interval_secs: read_env_u64(ENV_POTATOMESH_POLL_INTERVAL)?,
},
matrix: MatrixOverrides {
homeserver: read_env_string(ENV_MATRIX_HOMESERVER),
as_token: read_env_string(ENV_MATRIX_AS_TOKEN),
server_name: read_env_string(ENV_MATRIX_SERVER_NAME),
room_id: read_env_string(ENV_MATRIX_ROOM_ID),
},
state: StateOverrides {
state_file: read_env_string(ENV_STATE_FILE),
},
})
}
/// Build overrides from secret files.
fn secret_overrides(secrets_dir: Option<&Path>) -> anyhow::Result<ConfigOverrides> {
let poll_interval = match read_secret_value(ENV_POTATOMESH_POLL_INTERVAL, secrets_dir)? {
Some(value) => Some(value.parse::<u64>().map_err(|err| {
anyhow::anyhow!(
"Invalid integer value for {} in secret file: {}",
ENV_POTATOMESH_POLL_INTERVAL,
err
)
})?),
None => None,
};
Ok(ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: read_secret_value(ENV_POTATOMESH_BASE_URL, secrets_dir)?,
poll_interval_secs: poll_interval,
},
matrix: MatrixOverrides {
homeserver: read_secret_value(ENV_MATRIX_HOMESERVER, secrets_dir)?,
as_token: read_secret_value(ENV_MATRIX_AS_TOKEN, secrets_dir)?,
server_name: read_secret_value(ENV_MATRIX_SERVER_NAME, secrets_dir)?,
room_id: read_secret_value(ENV_MATRIX_ROOM_ID, secrets_dir)?,
},
state: StateOverrides {
state_file: read_secret_value(ENV_STATE_FILE, secrets_dir)?,
},
})
}
/// Resolve the effective secrets directory for default *_FILE lookups.
fn resolve_secrets_dir(container_defaults: bool) -> Option<PathBuf> {
if let Some(dir) = read_env_string(ENV_SECRETS_DIR) {
return Some(PathBuf::from(dir));
}
if container_defaults {
return Some(PathBuf::from(DEFAULT_SECRETS_DIR));
}
None
}
/// Resolve the config path, honoring env and CLI overrides.
fn resolve_config_path(container_defaults: bool, overrides: &BootstrapOverrides) -> String {
if let Some(path) = read_env_string(ENV_CONFIG_PATH) {
return path;
}
if let Some(path) = &overrides.config_path {
return path.clone();
}
if container_defaults {
DEFAULT_CONTAINER_CONFIG_PATH.to_string()
} else {
DEFAULT_CONFIG_PATH.to_string()
}
}
/// Resolve whether container defaults should be active.
fn resolve_container_defaults(
in_container: bool,
overrides: &BootstrapOverrides,
) -> anyhow::Result<bool> {
if let Some(env_value) = read_env_bool(ENV_CONTAINER_DEFAULTS)? {
return Ok(env_value);
}
if let Some(cli_value) = overrides.container_defaults {
return Ok(cli_value);
}
Ok(in_container)
}
/// Apply default values and return a fully populated config.
fn finalize_config(overrides: ConfigOverrides, container_defaults: bool) -> anyhow::Result<Config> {
let base_url = overrides
.potatomesh
.base_url
.ok_or_else(|| anyhow::anyhow!("potatomesh.base_url is required"))?;
let poll_interval_secs = overrides.potatomesh.poll_interval_secs.unwrap_or({
if container_defaults {
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
} else {
DEFAULT_POLL_INTERVAL_SECS
}
});
let homeserver = overrides
.matrix
.homeserver
.ok_or_else(|| anyhow::anyhow!("matrix.homeserver is required"))?;
let as_token = overrides
.matrix
.as_token
.ok_or_else(|| anyhow::anyhow!("matrix.as_token is required"))?;
let server_name = overrides
.matrix
.server_name
.ok_or_else(|| anyhow::anyhow!("matrix.server_name is required"))?;
let room_id = overrides
.matrix
.room_id
.ok_or_else(|| anyhow::anyhow!("matrix.room_id is required"))?;
let state_file = overrides.state.state_file.unwrap_or_else(|| {
if container_defaults {
DEFAULT_CONTAINER_STATE_FILE.to_string()
} else {
DEFAULT_STATE_FILE.to_string()
}
});
Ok(Config {
potatomesh: PotatomeshConfig {
base_url,
poll_interval_secs,
},
matrix: MatrixConfig {
homeserver,
as_token,
server_name,
room_id,
},
state: StateConfig { state_file },
})
}
impl Config {
/// Load config from a specific path.
#[allow(dead_code)]
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)
}
/// Load config from the default path in the working directory.
#[allow(dead_code)]
pub fn from_default_path() -> anyhow::Result<Self> {
let path = "Config.toml";
let path = DEFAULT_CONFIG_PATH;
if !Path::new(path).exists() {
anyhow::bail!("Config file {path} not found");
}
Self::load_from_file(path)
}
/// Load configuration by merging TOML, CLI, env, and secret values.
pub fn load_with_overrides(overrides: BootstrapOverrides) -> anyhow::Result<ConfigBootstrap> {
let in_container = detect_container();
let container_defaults = resolve_container_defaults(in_container, &overrides)?;
let config_path = resolve_config_path(container_defaults, &overrides);
let secrets_dir = resolve_secrets_dir(container_defaults);
let mut merged = ConfigOverrides::default();
if let Some(file_overrides) = load_optional_config(&config_path)? {
merged.merge(file_overrides);
} else {
tracing::warn!(
"Config file {} not found; continuing with overrides",
config_path
);
}
merged.merge(overrides.values);
merged.merge(env_overrides()?);
merged.merge(secret_overrides(secrets_dir.as_deref())?);
let config = finalize_config(merged, container_defaults)?;
let context = RuntimeContext {
in_container,
container_defaults,
config_path,
secrets_dir,
};
Ok(ConfigBootstrap { config, context })
}
}
#[cfg(test)]
@@ -62,6 +536,44 @@ mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
use std::{env, path::PathBuf};
struct EnvGuard {
key: String,
value: Option<String>,
}
impl EnvGuard {
fn set<K: Into<String>>(key: K, value: &str) -> Self {
let key = key.into();
let previous = env::var(&key).ok();
env::set_var(&key, value);
Self {
key,
value: previous,
}
}
fn unset<K: Into<String>>(key: K) -> Self {
let key = key.into();
let previous = env::var(&key).ok();
env::remove_var(&key);
Self {
key,
value: previous,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.value {
env::set_var(&self.key, value);
} else {
env::remove_var(&self.key);
}
}
}
#[test]
fn parse_minimal_config_from_toml_str() {
@@ -154,4 +666,190 @@ mod tests {
let result = Config::from_default_path();
assert!(result.is_ok());
}
#[test]
fn detect_container_from_env_values() {
assert!(detect_container_from(Some("1"), None));
assert!(detect_container_from(Some("true"), None));
assert!(!detect_container_from(Some("0"), None));
assert!(!detect_container_from(Some("false"), None));
assert!(!detect_container_from(Some("FALSE"), None));
}
#[test]
fn detect_container_from_cgroup_markers() {
let cgroup = "12:memory:/docker/abcd\n11:pids:/kubepods.slice";
assert!(detect_container_from(None, Some(cgroup)));
let host_cgroup = "0::/user.slice/user-1000.slice";
assert!(!detect_container_from(None, Some(host_cgroup)));
}
#[test]
#[serial]
fn env_overrides_cli_and_toml() {
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_poll = EnvGuard::set(ENV_POTATOMESH_POLL_INTERVAL, "25");
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let toml_str = r#"
[potatomesh]
base_url = "https://toml.example/"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "toml-token"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "toml_state.json"
"#;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", toml_str).unwrap();
let overrides = BootstrapOverrides {
config_path: Some(file.path().to_str().unwrap().to_string()),
container_defaults: Some(false),
values: ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: Some("https://cli.example/".to_string()),
poll_interval_secs: Some(15),
},
matrix: MatrixOverrides {
as_token: Some("cli-token".to_string()),
..Default::default()
},
state: StateOverrides {
state_file: Some("cli_state.json".to_string()),
},
},
};
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.potatomesh.base_url, "https://env.example/");
assert_eq!(result.config.potatomesh.poll_interval_secs, 25);
assert_eq!(result.config.matrix.as_token, "env-token");
assert_eq!(result.config.state.state_file, "cli_state.json");
}
#[test]
#[serial]
fn secret_file_overrides_env_values() {
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_homeserver = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let _guard_env_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let secret_file = tempfile::NamedTempFile::new().unwrap();
fs::write(secret_file.path(), "secret-token").unwrap();
let _guard_secret = EnvGuard::set(
format!("{}_FILE", ENV_MATRIX_AS_TOKEN),
secret_file.path().to_str().unwrap(),
);
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.matrix.as_token, "secret-token");
}
#[test]
#[serial]
fn container_defaults_change_paths_and_intervals() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::unset(ENV_CONTAINER_DEFAULTS);
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert!(result.context.in_container);
assert!(result.context.container_defaults);
assert_eq!(result.context.config_path, DEFAULT_CONTAINER_CONFIG_PATH);
assert_eq!(result.config.state.state_file, DEFAULT_CONTAINER_STATE_FILE);
assert_eq!(
result.config.potatomesh.poll_interval_secs,
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
);
}
#[test]
#[serial]
fn container_defaults_can_be_disabled() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert!(result.context.in_container);
assert!(!result.context.container_defaults);
assert_eq!(result.context.config_path, DEFAULT_CONFIG_PATH);
assert_eq!(result.config.state.state_file, DEFAULT_STATE_FILE);
assert_eq!(
result.config.potatomesh.poll_interval_secs,
DEFAULT_POLL_INTERVAL_SECS
);
}
#[test]
#[serial]
fn secrets_dir_defaults_are_used_when_present() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "1");
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let temp_dir = tempfile::tempdir().unwrap();
let secret_path = temp_dir.path().join(ENV_MATRIX_AS_TOKEN);
fs::write(&secret_path, "dir-token").unwrap();
let _guard_dir = EnvGuard::set(ENV_SECRETS_DIR, temp_dir.path().to_str().unwrap());
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.matrix.as_token, "dir-token");
assert_eq!(
result.context.secrets_dir,
Some(PathBuf::from(temp_dir.path()))
);
}
#[test]
#[serial]
fn read_env_bool_rejects_invalid_values() {
let _guard = EnvGuard::set("POTATOMESH_TEST_BOOL", "maybe");
let result = read_env_bool("POTATOMESH_TEST_BOOL");
assert!(result.is_err());
}
#[test]
#[serial]
fn read_env_u64_rejects_invalid_values() {
let _guard = EnvGuard::set("POTATOMESH_TEST_U64", "not-a-number");
let result = read_env_u64("POTATOMESH_TEST_U64");
assert!(result.is_err());
}
#[test]
fn read_secret_file_rejects_empty_contents() {
let file = tempfile::NamedTempFile::new().unwrap();
fs::write(file.path(), " ").unwrap();
let result = read_secret_file(file.path());
assert!(result.is_err());
}
}

View 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 potatomesh;
@@ -19,13 +20,22 @@ mod potatomesh;
use std::{fs, path::Path};
use anyhow::Result;
use clap::Parser;
use tokio::time::{sleep, Duration};
use tracing::{error, info};
use crate::cli::Cli;
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage, PotatoNode};
fn format_runtime_context(context: &config::RuntimeContext) -> String {
format!(
"Runtime context: in_container={} container_defaults={} config_path={} secrets_dir={:?}",
context.in_container, context.container_defaults, context.config_path, context.secrets_dir
)
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct BridgeState {
/// Highest message id processed by the bridge.
@@ -172,8 +182,12 @@ async fn main() -> Result<()> {
)
.init();
let cfg = Config::from_default_path()?;
info!("Loaded config: {:?}", cfg);
let cli = Cli::parse();
let bootstrap = Config::load_with_overrides(cli.into_overrides())?;
info!("Loaded config: {:?}", bootstrap.config);
info!("{}", format_runtime_context(&bootstrap.context));
let cfg = bootstrap.config;
let http = reqwest::Client::builder().build()?;
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
@@ -723,4 +737,20 @@ mod tests {
assert_eq!(state.last_message_id, Some(100));
}
#[test]
fn format_runtime_context_includes_flags() {
let context = config::RuntimeContext {
in_container: true,
container_defaults: false,
config_path: "/app/Config.toml".to_string(),
secrets_dir: Some(std::path::PathBuf::from("/run/secrets")),
};
let rendered = format_runtime_context(&context);
assert!(rendered.contains("in_container=true"));
assert!(rendered.contains("container_defaults=false"));
assert!(rendered.contains("/app/Config.toml"));
assert!(rendered.contains("/run/secrets"));
}
}