설정과 비밀값 문서의 검증 코드다.
//! 환경 변수에서 타입 있는 설정을 읽고, 시작할 때 한 번에 검사해 틀리면 바로 멈춘다(fail fast).
//! 비밀값은 Secret<T>로 감싸 Debug·Display가 가려진 글자만 찍게 한다.
//! env::var(..).unwrap()의 panic 메시지에 변수 이름이 없는 것도 확인한다.
//! 의존성: thiserror = "2"
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/44_configuration-secrets_verify.rs"
use std::collections::HashMap;
use std::fmt;
use std::net::SocketAddr;
use std::time::Duration;
mod secret {
use std::fmt;
/// 비밀값 감싸개. 안의 값은 expose()로만 꺼낸다. 필드가 private이라 `.0`으로 꺼낼 수 없다.
#[derive(Clone)]
pub struct Secret<T>(T);
impl<T> Secret<T> {
pub fn new(value: T) -> Self {
Secret(value)
}
/// 비밀값을 꺼내는 길을 이 메서드 하나로 모은다. 검색 한 번으로 꺼내는 곳을 모두 찾는다.
pub fn expose(&self) -> &T {
&self.0
}
}
impl<T> fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([REDACTED])")
}
}
impl<T> fmt::Display for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
}
use secret::Secret;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Env {
Dev,
Prod,
}
#[derive(Debug)]
struct Config {
bind_addr: SocketAddr,
database_url: Secret<String>,
api_token: Secret<String>,
request_timeout: Duration,
env: Env,
}
#[derive(Debug, thiserror::Error, PartialEq)]
enum ConfigError {
#[error("{0} is required")]
Missing(&'static str),
#[error("{key} has invalid value {value:?}: {reason}")]
Invalid {
key: &'static str,
value: String,
reason: &'static str,
},
}
/// 모든 오류를 모아서 한 번에 보여 준다. 첫 오류에서 멈추면 고치고 다시 띄우기를 여러 번 해야 한다.
#[derive(Debug)]
struct ConfigErrors(Vec<ConfigError>);
impl fmt::Display for ConfigErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "invalid configuration ({} problems):", self.0.len())?;
for e in &self.0 {
writeln!(f, " - {e}")?;
}
Ok(())
}
}
impl std::error::Error for ConfigErrors {}
impl Config {
/// lookup은 "이름 -> 값"을 돌려주는 함수. 운영에서는 std::env::var, 테스트에서는 HashMap.
fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Config, ConfigErrors> {
let mut errs = Vec::new();
let bind_addr = match lookup("APP_BIND_ADDR") {
None => Some(SocketAddr::from(([127, 0, 0, 1], 8080))), // 기본값
Some(v) => match v.parse() {
Ok(a) => Some(a),
Err(_) => {
errs.push(ConfigError::Invalid {
key: "APP_BIND_ADDR",
value: v,
reason: "expected host:port",
});
None
}
},
};
let database_url = lookup("DATABASE_URL").map(Secret::new);
if database_url.is_none() {
errs.push(ConfigError::Missing("DATABASE_URL"));
}
// 비밀값은 잘못돼도 값 자체를 오류 메시지에 넣지 않는다.
let api_token = match lookup("APP_API_TOKEN") {
None => {
errs.push(ConfigError::Missing("APP_API_TOKEN"));
None
}
Some(t) if t.len() < 16 => {
errs.push(ConfigError::Invalid {
key: "APP_API_TOKEN",
value: "[REDACTED]".into(),
reason: "must be at least 16 characters",
});
None
}
Some(t) => Some(Secret::new(t)),
};
let request_timeout = match lookup("APP_TIMEOUT_MS").map(|v| (v.parse::<u64>(), v)) {
None => Some(Duration::from_millis(5_000)),
Some((Ok(ms), _)) if (1..=60_000).contains(&ms) => Some(Duration::from_millis(ms)),
Some((_, v)) => {
errs.push(ConfigError::Invalid {
key: "APP_TIMEOUT_MS",
value: v,
reason: "expected 1..=60000",
});
None
}
};
let env = match lookup("APP_ENV").as_deref() {
None | Some("dev") => Some(Env::Dev),
Some("prod") => Some(Env::Prod),
Some(other) => {
errs.push(ConfigError::Invalid {
key: "APP_ENV",
value: other.into(),
reason: "expected dev or prod",
});
None
}
};
// 여러 값을 함께 봐야 하는 규칙: 운영에서는 외부에 열린 주소만 허용.
if let (Some(Env::Prod), Some(a)) = (&env, &bind_addr)
&& a.ip().is_loopback()
{
errs.push(ConfigError::Invalid {
key: "APP_BIND_ADDR",
value: a.to_string(),
reason: "prod must not bind to loopback",
});
}
match (bind_addr, database_url, api_token, request_timeout, env) {
(
Some(bind_addr),
Some(database_url),
Some(api_token),
Some(request_timeout),
Some(env),
) if errs.is_empty() => Ok(Config {
bind_addr,
database_url,
api_token,
request_timeout,
env,
}),
_ => Err(ConfigErrors(errs)),
}
}
fn from_env() -> Result<Config, ConfigErrors> {
Config::from_lookup(|k| std::env::var(k).ok())
}
}
fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn load(pairs: &[(&str, &str)]) -> Result<Config, ConfigErrors> {
let m = map(pairs);
Config::from_lookup(|k| m.get(k).cloned())
}
/// 시작 순서: 설정 → (실패면 여기서 끝) → 소켓 열기. 설정이 틀리면 포트를 열기 전에 멈춘다.
fn start(pairs: &[(&str, &str)], log: &mut Vec<String>) -> Result<(), ConfigErrors> {
let cfg = load(pairs)?;
log.push(format!("config loaded: {cfg:?}"));
log.push(format!("binding {}", cfg.bind_addr));
Ok(())
}
fn compile_errors(code: &str) -> String {
let dir = std::env::temp_dir().join(format!("vr_{}_{}", std::process::id(), code.len()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("snippet.rs");
std::fs::write(&file, code).unwrap();
let out = std::process::Command::new("rustc")
.args([
"--edition",
"2024",
"--crate-type=lib",
"--emit=metadata",
"--out-dir",
])
.arg(&dir)
.arg(&file)
.output()
.expect("rustc 실행");
String::from_utf8_lossy(&out.stderr).into_owned()
}
const TOKEN: &str = "tok_live_0123456789abcdef";
fn main() {
let mut ok = true;
let mut check = |cond: bool, what: String| {
println!("[{}] {what}", if cond { "ok" } else { "FAIL" });
ok &= cond;
};
// 1. 필수값만 주면 나머지는 기본값.
let cfg = load(&[
("DATABASE_URL", "sqlite://app.db?password=pw1"),
("APP_API_TOKEN", TOKEN),
])
.expect("ok");
check(
cfg.bind_addr.to_string() == "127.0.0.1:8080"
&& cfg.request_timeout == Duration::from_secs(5)
&& cfg.env == Env::Dev,
"기본값: 127.0.0.1:8080, 5초, dev".into(),
);
let dbg = format!("{cfg:?}");
println!(" {dbg}");
check(
!dbg.contains(TOKEN) && !dbg.contains("pw1"),
"Debug에 토큰과 DB 비밀번호가 없다".into(),
);
check(
format!("{}", cfg.api_token) == "[REDACTED]",
"Display도 가린다".into(),
);
check(
cfg.api_token.expose() == TOKEN,
"expose()로만 진짜 값을 꺼낸다".into(),
);
check(
cfg.database_url.expose().starts_with("sqlite://"),
"DB 주소도 expose()로 꺼낸다".into(),
);
// 2. 여러 오류를 한꺼번에 모은다.
let err = load(&[
("APP_BIND_ADDR", "localhost"),
("APP_API_TOKEN", "short-secret"),
("APP_TIMEOUT_MS", "0"),
])
.unwrap_err();
let text = err.to_string();
println!("{text}");
check(
err.0.len() == 4,
format!("오류 {}개를 한 번에 보고", err.0.len()),
);
check(
err.0.contains(&ConfigError::Missing("DATABASE_URL")),
"DATABASE_URL 누락".into(),
);
check(
!text.contains("short-secret"),
"잘못된 비밀값도 오류 메시지에 찍지 않는다".into(),
);
// 3. 여러 값을 함께 보는 규칙.
let err = load(&[
("DATABASE_URL", "x"),
("APP_API_TOKEN", TOKEN),
("APP_ENV", "prod"),
])
.unwrap_err();
check(
err.to_string().contains("prod must not bind to loopback"),
"prod인데 127.0.0.1이면 거부".into(),
);
check(
load(&[
("DATABASE_URL", "x"),
("APP_API_TOKEN", TOKEN),
("APP_ENV", "prod"),
("APP_BIND_ADDR", "0.0.0.0:80"),
])
.is_ok(),
"prod + 0.0.0.0:80은 통과".into(),
);
// 4. fail fast: 설정이 틀리면 소켓 단계까지 가지 않는다.
let mut log = Vec::new();
let r = start(&[("APP_API_TOKEN", TOKEN)], &mut log);
check(
r.is_err() && log.is_empty(),
"설정 실패 시 binding 로그 없음".into(),
);
let r = start(&[("DATABASE_URL", "x"), ("APP_API_TOKEN", TOKEN)], &mut log);
check(
r.is_ok() && log.len() == 2 && !log[0].contains(TOKEN),
"성공 시 로그에도 토큰 없음".into(),
);
// 5. 진짜 환경 변수 읽기: 이 프로세스에는 DATABASE_URL이 없다고 가정하지 않고, 결과 타입만 확인한다.
let from_env = Config::from_env();
check(
from_env.is_ok() || !from_env.as_ref().unwrap_err().0.is_empty(),
"from_env()는 std::env::var로 같은 검사를 한다".into(),
);
// 5-1. 흔한 실수: env::var(..).unwrap()의 panic 메시지에는 변수 이름이 없다.
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let caught = std::panic::catch_unwind(|| std::env::var("VAULT_SURELY_MISSING_VAR").unwrap());
std::panic::set_hook(prev);
let panic_msg = caught
.err()
.and_then(|e| e.downcast_ref::<String>().cloned())
.unwrap_or_default();
println!(" unwrap panic: {panic_msg}");
check(
panic_msg.contains("NotPresent") && !panic_msg.contains("VAULT_SURELY_MISSING_VAR"),
"unwrap() panic은 NotPresent만 말하고 어떤 변수인지 말하지 않는다".into(),
);
// 6. 컴파일러가 막아 주는 것.
let base = "mod secret { pub struct Secret<T>(T); impl<T> Secret<T> { pub fn new(v: T) -> Self { Secret(v) } } }\n";
let e = compile_errors(&format!(
"{base}pub fn f() -> String {{ let s = secret::Secret::new(String::new()); s.0 }}"
));
check(e.contains("E0616"), "모듈 밖에서 s.0 접근 -> E0616".into());
let e = compile_errors("pub fn f() { std::env::set_var(\"K\", \"V\"); }");
check(
e.contains("E0133"),
"2024 에디션: unsafe 없이 set_var -> E0133".into(),
);
assert!(ok);
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_redacts_secret() {
let s = Secret::new("abc".to_string());
assert_eq!(format!("{s:?}"), "Secret([REDACTED])");
assert_eq!(format!("{s}"), "[REDACTED]");
}
#[test]
fn invalid_timeout_reported_with_key() {
let err = load(&[
("DATABASE_URL", "x"),
("APP_API_TOKEN", TOKEN),
("APP_TIMEOUT_MS", "abc"),
])
.unwrap_err();
assert_eq!(err.0.len(), 1);
assert!(
err.to_string()
.contains("APP_TIMEOUT_MS has invalid value \"abc\"")
);
}
}