mirror of
https://github.com/telemt/telemt.git
synced 2026-07-01 07:11:11 +03:00
673 lines
25 KiB
Rust
673 lines
25 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use hyper::header::IF_MATCH;
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::config::{ProxyConfig, RateLimitBps};
|
|
|
|
use super::model::ApiFailure;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(super) enum AccessSection {
|
|
Users,
|
|
UserEnabled,
|
|
UserAdTags,
|
|
UserMaxTcpConns,
|
|
UserExpirations,
|
|
UserDataQuota,
|
|
UserRateLimits,
|
|
UserMaxUniqueIps,
|
|
}
|
|
|
|
impl AccessSection {
|
|
fn table_name(self) -> &'static str {
|
|
match self {
|
|
Self::Users => "access.users",
|
|
Self::UserEnabled => "access.user_enabled",
|
|
Self::UserAdTags => "access.user_ad_tags",
|
|
Self::UserMaxTcpConns => "access.user_max_tcp_conns",
|
|
Self::UserExpirations => "access.user_expirations",
|
|
Self::UserDataQuota => "access.user_data_quota",
|
|
Self::UserRateLimits => "access.user_rate_limits",
|
|
Self::UserMaxUniqueIps => "access.user_max_unique_ips",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn parse_if_match(headers: &hyper::HeaderMap) -> Option<String> {
|
|
headers
|
|
.get(IF_MATCH)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.trim_matches('"').to_string())
|
|
}
|
|
|
|
pub(super) async fn ensure_expected_revision(
|
|
config_path: &Path,
|
|
expected_revision: Option<&str>,
|
|
) -> Result<(), ApiFailure> {
|
|
let Some(expected) = expected_revision else {
|
|
return Ok(());
|
|
};
|
|
let current = current_revision(config_path).await?;
|
|
if current != expected {
|
|
return Err(ApiFailure::new(
|
|
hyper::StatusCode::CONFLICT,
|
|
"revision_conflict",
|
|
"Config revision mismatch",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn current_revision(config_path: &Path) -> Result<String, ApiFailure> {
|
|
let content = tokio::fs::read_to_string(config_path)
|
|
.await
|
|
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
Ok(compute_revision(&content))
|
|
}
|
|
|
|
pub(super) fn compute_revision(content: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(content.as_bytes());
|
|
hex::encode(hasher.finalize())
|
|
}
|
|
|
|
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
|
let config_path = config_path.to_path_buf();
|
|
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
|
|
.await
|
|
.map_err(|e| ApiFailure::internal(format!("failed to join config loader: {}", e)))?
|
|
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(super) async fn save_config_to_disk(
|
|
config_path: &Path,
|
|
cfg: &ProxyConfig,
|
|
) -> Result<String, ApiFailure> {
|
|
let serialized = toml::to_string_pretty(cfg)
|
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
|
write_atomic(config_path.to_path_buf(), serialized.clone()).await?;
|
|
Ok(compute_revision(&serialized))
|
|
}
|
|
|
|
/// Top-level config tables that may be edited via the config API.
|
|
///
|
|
/// Intentionally excluded (defense-in-depth, enforces the spec's per-node
|
|
/// identity invariant at the Telemt layer too):
|
|
///
|
|
/// - `access` : owned by the users API.
|
|
/// - `server` : carries per-node identity (`port`, `api`/`api_bind`, listeners).
|
|
/// - `network` : carries per-node identity (`ipv4`/`ipv6`).
|
|
/// - `show_link` : legacy top-level scalar/array (not a `[table]`), superseded
|
|
/// by the editable `general.links.show` sub-table. The
|
|
/// section-upsert machinery here only handles `[table]` /
|
|
/// `[[array-of-tables]]` blocks; a bare top-level key cannot be
|
|
/// located or replaced safely, so it is edited via `general`.
|
|
///
|
|
/// A future field-level allowlist can re-admit specific safe fields
|
|
/// (e.g. `network.dns_overrides`) without opening the whole section.
|
|
pub(super) const EDITABLE_SECTIONS: &[&str] = &[
|
|
"general",
|
|
"timeouts",
|
|
"censorship",
|
|
"upstreams",
|
|
"dc_overrides",
|
|
];
|
|
|
|
/// Re-render the given top-level tables from `cfg` and upsert each into the
|
|
/// on-disk file, preserving every untouched section (and its comments).
|
|
pub(super) async fn save_sections_to_disk(
|
|
config_path: &Path,
|
|
cfg: &ProxyConfig,
|
|
sections: &[&str],
|
|
) -> Result<String, ApiFailure> {
|
|
let mut content = tokio::fs::read_to_string(config_path)
|
|
.await
|
|
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
|
|
for section in sections {
|
|
let rendered = render_top_level_section(cfg, section)?;
|
|
content = upsert_toml_table(&content, section, &rendered);
|
|
}
|
|
|
|
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
|
Ok(compute_revision(&content))
|
|
}
|
|
|
|
/// Render one top-level table as `[section]\n...\n` (or `[[upstreams]]` array
|
|
/// of tables) from the typed `cfg`. Serializes via the `toml` crate so the
|
|
/// output matches the canonical format Telemt parses.
|
|
fn render_top_level_section(cfg: &ProxyConfig, section: &str) -> Result<String, ApiFailure> {
|
|
let value = toml::Value::try_from(cfg)
|
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
|
let table = value
|
|
.get(section)
|
|
.ok_or_else(|| ApiFailure::internal(format!("unknown section: {}", section)))?;
|
|
|
|
// upstreams is an array-of-tables -> render as [[upstreams]] blocks.
|
|
if let toml::Value::Array(items) = table {
|
|
let mut out = String::new();
|
|
for item in items {
|
|
out.push_str(&format!("[[{}]]\n", section));
|
|
out.push_str(&toml::to_string(item).map_err(|e| {
|
|
ApiFailure::internal(format!("failed to serialize {}: {}", section, e))
|
|
})?);
|
|
if !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
return Ok(out);
|
|
}
|
|
|
|
// Serialize the table *inside a wrapper keyed by `section`* so the `toml`
|
|
// crate emits correctly dotted headers for nested sub-tables, e.g.
|
|
// `[general]` + `[general.modes]` + `[general.links]`. Serializing the
|
|
// inner table alone would render bare `[modes]`/`[links]` headers, which
|
|
// would leak as duplicate top-level tables and break config load.
|
|
let mut wrapper = toml::value::Table::new();
|
|
wrapper.insert(section.to_string(), table.clone());
|
|
let mut out = toml::to_string(&toml::Value::Table(wrapper))
|
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize {}: {}", section, e)))?;
|
|
if !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub(super) async fn save_access_sections_to_disk(
|
|
config_path: &Path,
|
|
cfg: &ProxyConfig,
|
|
sections: &[AccessSection],
|
|
) -> Result<String, ApiFailure> {
|
|
let mut content = tokio::fs::read_to_string(config_path)
|
|
.await
|
|
.map_err(|e| ApiFailure::internal(format!("failed to read config: {}", e)))?;
|
|
|
|
let mut applied = Vec::new();
|
|
for section in sections {
|
|
if applied.contains(section) {
|
|
continue;
|
|
}
|
|
if find_toml_table_bounds(&content, section.table_name()).is_none()
|
|
&& access_section_is_empty(cfg, *section)
|
|
{
|
|
applied.push(*section);
|
|
continue;
|
|
}
|
|
let rendered = render_access_section(cfg, *section)?;
|
|
content = upsert_toml_table(&content, section.table_name(), &rendered);
|
|
applied.push(*section);
|
|
}
|
|
|
|
write_atomic(config_path.to_path_buf(), content.clone()).await?;
|
|
Ok(compute_revision(&content))
|
|
}
|
|
|
|
fn render_access_section(cfg: &ProxyConfig, section: AccessSection) -> Result<String, ApiFailure> {
|
|
let body = match section {
|
|
AccessSection::Users => {
|
|
let rows: BTreeMap<String, String> = cfg
|
|
.access
|
|
.users
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserEnabled => {
|
|
let rows: BTreeMap<String, bool> = cfg
|
|
.access
|
|
.user_enabled
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserAdTags => {
|
|
let rows: BTreeMap<String, String> = cfg
|
|
.access
|
|
.user_ad_tags
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserMaxTcpConns => {
|
|
let rows: BTreeMap<String, usize> = cfg
|
|
.access
|
|
.user_max_tcp_conns
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserExpirations => {
|
|
let rows: BTreeMap<String, DateTime<Utc>> = cfg
|
|
.access
|
|
.user_expirations
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserDataQuota => {
|
|
let rows: BTreeMap<String, u64> = cfg
|
|
.access
|
|
.user_data_quota
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
AccessSection::UserRateLimits => {
|
|
let rows: BTreeMap<String, RateLimitBps> = cfg
|
|
.access
|
|
.user_rate_limits
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_rate_limit_body(&rows)?
|
|
}
|
|
AccessSection::UserMaxUniqueIps => {
|
|
let rows: BTreeMap<String, usize> = cfg
|
|
.access
|
|
.user_max_unique_ips
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), *value))
|
|
.collect();
|
|
serialize_table_body(&rows)?
|
|
}
|
|
};
|
|
|
|
let mut out = format!("[{}]\n", section.table_name());
|
|
if !body.is_empty() {
|
|
out.push_str(&body);
|
|
}
|
|
if !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn access_section_is_empty(cfg: &ProxyConfig, section: AccessSection) -> bool {
|
|
match section {
|
|
AccessSection::Users => cfg.access.users.is_empty(),
|
|
AccessSection::UserEnabled => cfg.access.user_enabled.is_empty(),
|
|
AccessSection::UserAdTags => cfg.access.user_ad_tags.is_empty(),
|
|
AccessSection::UserMaxTcpConns => cfg.access.user_max_tcp_conns.is_empty(),
|
|
AccessSection::UserExpirations => cfg.access.user_expirations.is_empty(),
|
|
AccessSection::UserDataQuota => cfg.access.user_data_quota.is_empty(),
|
|
AccessSection::UserRateLimits => cfg.access.user_rate_limits.is_empty(),
|
|
AccessSection::UserMaxUniqueIps => cfg.access.user_max_unique_ips.is_empty(),
|
|
}
|
|
}
|
|
|
|
fn serialize_table_body<T: Serialize>(value: &T) -> Result<String, ApiFailure> {
|
|
toml::to_string(value)
|
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize access section: {}", e)))
|
|
}
|
|
|
|
fn serialize_rate_limit_body(rows: &BTreeMap<String, RateLimitBps>) -> Result<String, ApiFailure> {
|
|
let mut out = String::new();
|
|
for (key, value) in rows {
|
|
let key = serialize_toml_key(key)?;
|
|
out.push_str(&format!(
|
|
"{key} = {{ up_bps = {}, down_bps = {} }}\n",
|
|
value.up_bps, value.down_bps
|
|
));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn serialize_toml_key(key: &str) -> Result<String, ApiFailure> {
|
|
let mut row = BTreeMap::new();
|
|
row.insert(key.to_string(), 0_u8);
|
|
let rendered = serialize_table_body(&row)?;
|
|
rendered
|
|
.split_once(" = ")
|
|
.map(|(key, _)| key.to_string())
|
|
.ok_or_else(|| ApiFailure::internal("failed to serialize TOML key"))
|
|
}
|
|
|
|
fn upsert_toml_table(source: &str, table_name: &str, replacement: &str) -> String {
|
|
let blocks = find_all_table_blocks(source, table_name);
|
|
if let Some(&(first_start, first_end)) = blocks.first() {
|
|
// Replace the first block in place and delete any further blocks that
|
|
// also belong to this table. Telemt writes a section's sub-tables
|
|
// contiguously, but a hand-edited config may scatter them; dropping the
|
|
// extras here prevents the duplicate-table corruption that would
|
|
// otherwise break config load.
|
|
let mut out = String::with_capacity(source.len() + replacement.len());
|
|
out.push_str(&source[..first_start]);
|
|
out.push_str(replacement);
|
|
let mut cursor = first_end;
|
|
for &(start, end) in &blocks[1..] {
|
|
out.push_str(&source[cursor..start]);
|
|
cursor = end;
|
|
}
|
|
out.push_str(&source[cursor..]);
|
|
return out;
|
|
}
|
|
|
|
let mut out = source.to_string();
|
|
if !out.is_empty() && !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
if !out.is_empty() {
|
|
out.push('\n');
|
|
}
|
|
out.push_str(replacement);
|
|
out
|
|
}
|
|
|
|
/// Whether a (comment-stripped, trimmed) TOML header line belongs to
|
|
/// `table_name`: the table itself (`[X]` / `[[X]]`) or any of its nested
|
|
/// sub-tables (`[X.…]` / `[[X.…]]`). The trailing dot guards against sibling
|
|
/// prefixes — `access.users` must not match `access.user_enabled`.
|
|
fn header_belongs_to(header: &str, table_name: &str) -> bool {
|
|
let body = match header.strip_prefix("[[").and_then(|h| h.strip_suffix("]]")) {
|
|
Some(body) => body,
|
|
None => match header.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
|
|
Some(body) => body,
|
|
None => return false,
|
|
},
|
|
};
|
|
let body = body.trim();
|
|
body == table_name
|
|
|| body
|
|
.strip_prefix(table_name)
|
|
.is_some_and(|rest| rest.starts_with('.'))
|
|
}
|
|
|
|
/// Locate the first contiguous byte range covering `table_name` and the nested
|
|
/// sub-tables immediately following it. Used for existence checks; see
|
|
/// [`find_all_table_blocks`] for the full set of (possibly scattered) blocks.
|
|
fn find_toml_table_bounds(source: &str, table_name: &str) -> Option<(usize, usize)> {
|
|
find_all_table_blocks(source, table_name).into_iter().next()
|
|
}
|
|
|
|
/// Locate every byte range that belongs to `table_name`: the table header and
|
|
/// its nested sub-tables. Returns one range per contiguous run, so a config
|
|
/// where a section's sub-tables are scattered (e.g. hand-edited) yields several
|
|
/// ranges — letting the caller collapse them into a single rendered block.
|
|
fn find_all_table_blocks(source: &str, table_name: &str) -> Vec<(usize, usize)> {
|
|
let mut blocks = Vec::new();
|
|
let mut offset = 0usize;
|
|
let mut start: Option<usize> = None;
|
|
|
|
for line in source.split_inclusive('\n') {
|
|
// Drop any inline comment so a hand-edited header like
|
|
// `[censorship] # note` still matches. Section names never contain `#`.
|
|
let header = line.trim().split('#').next().unwrap_or("").trim();
|
|
let is_header = header.starts_with('[');
|
|
if let Some(start_offset) = start {
|
|
if is_header && !header_belongs_to(header, table_name) {
|
|
blocks.push((start_offset, offset));
|
|
start = None;
|
|
}
|
|
}
|
|
if start.is_none() && header_belongs_to(header, table_name) {
|
|
start = Some(offset);
|
|
}
|
|
offset = offset.saturating_add(line.len());
|
|
}
|
|
|
|
if let Some(start_offset) = start {
|
|
blocks.push((start_offset, source.len()));
|
|
}
|
|
blocks
|
|
}
|
|
|
|
async fn write_atomic(path: PathBuf, contents: String) -> Result<(), ApiFailure> {
|
|
tokio::task::spawn_blocking(move || write_atomic_sync(&path, &contents))
|
|
.await
|
|
.map_err(|e| ApiFailure::internal(format!("failed to join writer: {}", e)))?
|
|
.map_err(|e| ApiFailure::internal(format!("failed to write config: {}", e)))
|
|
}
|
|
|
|
fn write_atomic_sync(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
let tmp_name = format!(
|
|
".{}.tmp-{}",
|
|
path.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("config.toml"),
|
|
rand::random::<u64>()
|
|
);
|
|
let tmp_path = parent.join(tmp_name);
|
|
|
|
let write_result = (|| {
|
|
let mut file = std::fs::OpenOptions::new()
|
|
.create_new(true)
|
|
.write(true)
|
|
.open(&tmp_path)?;
|
|
file.write_all(contents.as_bytes())?;
|
|
file.sync_all()?;
|
|
std::fs::rename(&tmp_path, path)?;
|
|
if let Ok(dir) = std::fs::File::open(parent) {
|
|
let _ = dir.sync_all();
|
|
}
|
|
Ok(())
|
|
})();
|
|
|
|
if write_result.is_err() {
|
|
let _ = std::fs::remove_file(&tmp_path);
|
|
}
|
|
write_result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn save_sections_preserves_other_tables_and_comments() {
|
|
let dir = std::env::temp_dir().join(format!("cfgtest-{}", rand::random::<u64>()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
"# top comment\n[censorship]\ntls_domain = \"old.example\"\n\n[server]\nport = 443\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.censorship.tls_domain = "new.example".to_string();
|
|
cfg.server.port = 443;
|
|
|
|
let rev = save_sections_to_disk(&path, &cfg, &["censorship"])
|
|
.await
|
|
.unwrap();
|
|
|
|
let written = std::fs::read_to_string(&path).unwrap();
|
|
assert!(written.contains("tls_domain = \"new.example\""));
|
|
assert!(written.contains("# top comment")); // untouched comment kept
|
|
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
|
assert_eq!(rev, compute_revision(&written));
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn find_bounds_matches_array_of_tables() {
|
|
let src =
|
|
"[server]\nport = 1\n\n[[upstreams]]\nkind = \"a\"\n\n[[upstreams]]\nkind = \"b\"\n";
|
|
let bounds = find_toml_table_bounds(src, "upstreams");
|
|
assert!(bounds.is_some(), "should locate [[upstreams]] block start");
|
|
let (start, end) = bounds.unwrap();
|
|
let slice = &src[start..end];
|
|
assert!(slice.starts_with("[[upstreams]]"));
|
|
assert!(slice.contains("kind = \"b\"")); // spans through the last upstream block
|
|
}
|
|
|
|
#[test]
|
|
fn find_bounds_matches_header_with_inline_comment() {
|
|
let src = "[censorship] # notes\ntls_domain = \"a\"\n\n[server]\nport = 1\n";
|
|
let bounds = find_toml_table_bounds(src, "censorship");
|
|
assert!(bounds.is_some(), "commented header must still match");
|
|
let (start, end) = bounds.unwrap();
|
|
let slice = &src[start..end];
|
|
assert!(slice.starts_with("[censorship] # notes"));
|
|
assert!(slice.contains("tls_domain"));
|
|
assert!(!slice.contains("[server]")); // terminates at the next header
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_general_section_keeps_subtables_dotted_without_duplicates() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
tokio::fs::write(
|
|
&path,
|
|
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
[general.links]\npublic_host = \"old.example\"\n\n[server]\nport = 443\n",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.prefer_ipv6 = true;
|
|
|
|
save_sections_to_disk(&path, &cfg, &["general"])
|
|
.await
|
|
.unwrap();
|
|
|
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
|
|
// No bare top-level [modes] / [links] headers leaked.
|
|
for line in written.lines() {
|
|
let header = line.trim();
|
|
assert_ne!(header, "[modes]", "leaked top-level [modes]:\n{written}");
|
|
assert_ne!(header, "[links]", "leaked top-level [links]:\n{written}");
|
|
}
|
|
|
|
// Sub-tables kept their dotted prefix exactly once each.
|
|
assert_eq!(
|
|
written.matches("[general.modes]").count(),
|
|
1,
|
|
"[general.modes] must appear exactly once:\n{written}"
|
|
);
|
|
assert_eq!(
|
|
written.matches("[general.links]").count(),
|
|
1,
|
|
"[general.links] must appear exactly once:\n{written}"
|
|
);
|
|
|
|
// Result parses (duplicate tables would error here).
|
|
toml::from_str::<toml::Value>(&written)
|
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
|
|
assert!(written.contains("[server]\nport = 443")); // untouched table kept
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_general_section_is_idempotent_across_repeated_saves() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
tokio::fs::write(
|
|
&path,
|
|
"[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
[general.links]\npublic_host = \"old.example\"\n",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.prefer_ipv6 = true;
|
|
|
|
save_sections_to_disk(&path, &cfg, &["general"])
|
|
.await
|
|
.unwrap();
|
|
save_sections_to_disk(&path, &cfg, &["general"])
|
|
.await
|
|
.unwrap();
|
|
|
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
assert_eq!(written.matches("[general.modes]").count(), 1, "{written}");
|
|
assert_eq!(written.matches("[general.links]").count(), 1, "{written}");
|
|
assert_eq!(written.matches("[general]").count(), 1, "{written}");
|
|
toml::from_str::<toml::Value>(&written)
|
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
}
|
|
|
|
#[test]
|
|
fn find_bounds_spans_dotted_subtables() {
|
|
let src = "[general]\nprefer_ipv6 = false\n\n[general.modes]\ntls = true\n\n\
|
|
[general.links]\npublic_host = \"a\"\n\n[server]\nport = 1\n";
|
|
let bounds = find_toml_table_bounds(src, "general");
|
|
assert!(bounds.is_some(), "should locate [general] block");
|
|
let (start, end) = bounds.unwrap();
|
|
let slice = &src[start..end];
|
|
assert!(slice.starts_with("[general]"));
|
|
assert!(slice.contains("[general.modes]")); // spans nested sub-tables
|
|
assert!(slice.contains("[general.links]"));
|
|
assert!(!slice.contains("[server]")); // terminates at the next unrelated header
|
|
}
|
|
|
|
#[test]
|
|
fn find_bounds_does_not_overrun_sibling_prefix() {
|
|
// access.users must not swallow access.user_enabled (dot guards the prefix).
|
|
let src = "[access.users]\nalice = \"x\"\n\n[access.user_enabled]\nalice = true\n";
|
|
let bounds = find_toml_table_bounds(src, "access.users").unwrap();
|
|
let slice = &src[bounds.0..bounds.1];
|
|
assert!(slice.starts_with("[access.users]"));
|
|
assert!(!slice.contains("[access.user_enabled]"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_general_handles_non_contiguous_subtables() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
// Hand-edited layout: [general.modes] sits AFTER an unrelated [server].
|
|
tokio::fs::write(
|
|
&path,
|
|
"[general]\nprefer_ipv6 = false\n\n[server]\nport = 443\n\n\
|
|
[general.modes]\ntls = true\n",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.prefer_ipv6 = true;
|
|
|
|
save_sections_to_disk(&path, &cfg, &["general"])
|
|
.await
|
|
.unwrap();
|
|
|
|
let written = tokio::fs::read_to_string(&path).await.unwrap();
|
|
assert_eq!(
|
|
written.matches("[general.modes]").count(),
|
|
1,
|
|
"non-contiguous [general.modes] must not duplicate:\n{written}"
|
|
);
|
|
toml::from_str::<toml::Value>(&written)
|
|
.unwrap_or_else(|e| panic!("written config must parse: {e}\n{written}"));
|
|
assert!(written.contains("[server]")); // unrelated section preserved
|
|
}
|
|
|
|
#[test]
|
|
fn render_user_rate_limits_section() {
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.access.user_rate_limits.insert(
|
|
"alice".to_string(),
|
|
RateLimitBps {
|
|
up_bps: 1024,
|
|
down_bps: 2048,
|
|
},
|
|
);
|
|
|
|
let rendered = render_access_section(&cfg, AccessSection::UserRateLimits)
|
|
.expect("section must render");
|
|
|
|
assert!(rendered.starts_with("[access.user_rate_limits]\n"));
|
|
assert!(rendered.contains("alice = { up_bps = 1024, down_bps = 2048 }"));
|
|
}
|
|
}
|