Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,12 @@ ENABLE_DA_TRACKING=false
# FAUCET_PRIVATE_KEY=0x...
# FAUCET_AMOUNT=0.01
# FAUCET_COOLDOWN_MINUTES=30

# Optional snapshot feature (daily pg_dump backups)
# SNAPSHOT_ENABLED=false
# SNAPSHOT_TIME=03:00 # UTC time (HH:MM) to run daily pg_dump
# SNAPSHOT_RETENTION=7 # Number of snapshot files to keep
# SNAPSHOT_DIR=/snapshots # Container path for snapshots
# SNAPSHOT_HOST_DIR=./snapshots # Host path mounted to SNAPSHOT_DIR
# UID=1000 # Optional: host UID for writable snapshot bind mounts
# GID=1000 # Optional: host GID for writable snapshot bind mounts
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Thumbs.db
*.log
logs/

# Local snapshot test artifacts
snapshots/

# Node (frontend)
frontend/node_modules/
frontend/dist/
Expand Down
6 changes: 4 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ RUN cargo build --release
# Server image
FROM alpine:3.21 AS server

RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates postgresql16-client

COPY --from=builder /app/target/release/atlas-server /usr/local/bin/

RUN addgroup -S atlas && adduser -S atlas -G atlas
RUN addgroup -S -g 1000 atlas && adduser -S -u 1000 atlas -G atlas \
&& mkdir -p /snapshots \
&& chown atlas:atlas /snapshots
USER atlas

EXPOSE 3000
Expand Down
1 change: 1 addition & 0 deletions backend/crates/atlas-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ tokio = { workspace = true }
tower = { workspace = true, features = ["util"] }
serde_json = { workspace = true }
sqlx = { workspace = true }
tempfile = "3"
30 changes: 29 additions & 1 deletion backend/crates/atlas-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ pub struct FaucetArgs {
value_name = "MINS",
help = "Cooldown period in minutes between faucet requests per address"
)]
pub cooldown_minutes: Option<u64>,
pub cooldown_minutes: Option<String>,
// FAUCET_PRIVATE_KEY is intentionally env-only (security: never pass secrets as CLI flags)
}

Expand Down Expand Up @@ -396,6 +396,34 @@ pub struct LogArgs {
pub format: String,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn empty_faucet_cooldown_env_is_ignored_when_faucet_disabled() {
unsafe {
std::env::set_var("DATABASE_URL", "postgres://atlas:atlas@localhost/atlas");
std::env::set_var("RPC_URL", "http://localhost:8545");
std::env::set_var("FAUCET_ENABLED", "false");
std::env::set_var("FAUCET_COOLDOWN_MINUTES", "");
}

let cli = Cli::try_parse_from(["atlas-server", "run"]).expect("parse cli");
match cli.command {
Command::Run(args) => assert_eq!(args.faucet.cooldown_minutes, Some(String::new())),
_ => panic!("expected run command"),
}

unsafe {
std::env::remove_var("DATABASE_URL");
std::env::remove_var("RPC_URL");
std::env::remove_var("FAUCET_ENABLED");
std::env::remove_var("FAUCET_COOLDOWN_MINUTES");
}
Comment on lines +403 to +423
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the exact test code at lines 403-423
head -425 backend/crates/atlas-server/src/cli.rs | tail -23 | cat -n

Repository: evstack/atlas

Length of output: 1114


🏁 Script executed:

# Search for other tests in this file that mutate env vars
rg "set_var|remove_var" backend/crates/atlas-server/src/cli.rs -B 3 -A 1

Repository: evstack/atlas

Length of output: 689


🏁 Script executed:

# Look for mutex/lock patterns used for env synchronization elsewhere in the crate
rg "Mutex|RwLock|lazy_static|once_cell" backend/crates/atlas-server/ -t rs --max-count 20

Repository: evstack/atlas

Length of output: 83


🏁 Script executed:

# Check if there's a scoped env helper in this crate
fd "env" backend/crates/atlas-server/src/ -t rs | head -20

Repository: evstack/atlas

Length of output: 226


🏁 Script executed:

# Search for "scoped" or "helper" patterns related to env in the broader crate
rg "scoped|helper.*env" backend/crates/ -t rs -i --max-count 15

Repository: evstack/atlas

Length of output: 83


🏁 Script executed:

# Search for Mutex, lazy_static, once_cell without specifying file type
rg "Mutex|lazy_static|once_cell" backend/crates/atlas-server/ -B 2 -A 1 | head -50

Repository: evstack/atlas

Length of output: 1268


🏁 Script executed:

# Look for other #[test] functions to see how they handle state
rg "#\[test\]" backend/crates/atlas-server/src/cli.rs -A 20 | head -80

Repository: evstack/atlas

Length of output: 945


🏁 Script executed:

# Search broader backend crates for env-related test patterns
rg "set_var|remove_var" backend/crates/ -B 5 -A 5 | head -60

Repository: evstack/atlas

Length of output: 4355


🏁 Script executed:

# Check if there's any synchronization mechanism defined in lib.rs or mod.rs files
find backend/crates/atlas-server/src -name "lib.rs" -o -name "mod.rs" | xargs cat 2>/dev/null | head -100

Repository: evstack/atlas

Length of output: 3082


🏁 Script executed:

# Search for where ENV_LOCK is actually used/locked in tests
rg "ENV_LOCK" backend/crates/atlas-server/src/config.rs -B 2 -A 5

Repository: evstack/atlas

Length of output: 6343


🏁 Script executed:

# Get the full test function that uses env vars in config.rs to see if it acquires the lock
rg "#\[test\]" backend/crates/atlas-server/src/config.rs -A 30 | head -80

Repository: evstack/atlas

Length of output: 2747


Guard env mutations with the established ENV_LOCK pattern.

This test mutates process-global env vars without synchronization. The crate already defines static ENV_LOCK: Mutex<()> in config.rs and uses it consistently across all env-mutating tests (e.g., let _lock = ENV_LOCK.lock().unwrap();). Apply the same pattern here to prevent races with parallel test execution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/crates/atlas-server/src/cli.rs` around lines 403 - 423, The test
empty_faucet_cooldown_env_is_ignored_when_faucet_disabled mutates process-wide
env vars unsafely; wrap the env set/remove operations with the existing ENV_LOCK
to prevent races by acquiring the lock at the start of the test (e.g., let _lock
= ENV_LOCK.lock().unwrap();) before any std::env::set_var/std::env::remove_var
calls and keep the lock held for the duration of the test so all env mutations
are synchronized with other tests that use ENV_LOCK from config.rs.

}
}

// ── db subcommand ─────────────────────────────────────────────────────────────

#[derive(Args)]
Expand Down
197 changes: 196 additions & 1 deletion backend/crates/atlas-server/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use alloy::primitives::U256;
use alloy::signers::local::PrivateKeySigner;
use anyhow::{bail, Context, Result};
use chrono::NaiveTime;
use std::{env, str::FromStr};

#[cfg(test)]
Expand Down Expand Up @@ -364,11 +365,14 @@ impl FaucetConfig {
bail!("faucet amount must be greater than 0");
}

let cooldown_minutes = args.cooldown_minutes.ok_or_else(|| {
let cooldown_minutes = parse_optional_env(args.cooldown_minutes.clone()).ok_or_else(|| {
anyhow::anyhow!(
"--atlas.faucet.cooldown-minutes (or FAUCET_COOLDOWN_MINUTES) must be set when faucet is enabled"
)
})?;
let cooldown_minutes = cooldown_minutes
.parse::<u64>()
.context("Invalid --atlas.faucet.cooldown-minutes / FAUCET_COOLDOWN_MINUTES")?;
if cooldown_minutes == 0 {
bail!("faucet cooldown must be greater than 0");
}
Expand All @@ -385,6 +389,72 @@ impl FaucetConfig {
}
}

#[derive(Clone)]
pub struct SnapshotConfig {
pub enabled: bool,
pub time: NaiveTime,
pub retention: u32,
pub dir: String,
pub database_url: String,
}

impl std::fmt::Debug for SnapshotConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SnapshotConfig")
.field("enabled", &self.enabled)
.field("time", &self.time)
.field("retention", &self.retention)
.field("dir", &self.dir)
.field("database_url", &"[redacted]")
.finish()
}
}

impl SnapshotConfig {
pub fn from_env(database_url: &str) -> Result<Self> {
let enabled = env::var("SNAPSHOT_ENABLED")
.unwrap_or_else(|_| "false".to_string())
.parse::<bool>()
.context("Invalid SNAPSHOT_ENABLED")?;

if !enabled {
return Ok(Self {
enabled,
time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(),
retention: 7,
dir: "/snapshots".to_string(),
database_url: database_url.to_string(),
});
}

let time_str = env::var("SNAPSHOT_TIME").unwrap_or_else(|_| "03:00".to_string());
let time = NaiveTime::parse_from_str(&time_str, "%H:%M")
.context("Invalid SNAPSHOT_TIME (expected HH:MM)")?;

let retention = env::var("SNAPSHOT_RETENTION")
.unwrap_or_else(|_| "7".to_string())
.parse::<u32>()
.context("Invalid SNAPSHOT_RETENTION")?;
if retention == 0 {
bail!("SNAPSHOT_RETENTION must be greater than 0");
}

let dir = env::var("SNAPSHOT_DIR").unwrap_or_else(|_| "/snapshots".to_string());
let dir = dir.trim().to_string();
if dir.is_empty() {
bail!("SNAPSHOT_DIR must not be empty");
}

Ok(Self {
enabled,
time,
retention,
dir,
database_url: database_url.to_string(),
})
}
}

fn parse_optional_env(val: Option<String>) -> Option<String> {
val.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
Expand Down Expand Up @@ -605,6 +675,26 @@ mod tests_from_run_args {
Some("/branding/dark.svg")
);
}

#[test]
fn faucet_blank_cooldown_is_treated_as_missing() {
let mut args = minimal_run_args();
args.faucet.enabled = true;
args.faucet.amount = Some("0.1".to_string());
args.faucet.cooldown_minutes = Some(" ".to_string());

unsafe {
env::set_var(
"FAUCET_PRIVATE_KEY",
"0x59c6995e998f97a5a0044966f0945382dbd8c5df5440d8d6d0d0f66f6d7d6a0d",
);
}
let err = FaucetConfig::from_faucet_args(&args.faucet).unwrap_err();
assert!(err.to_string().contains("cooldown-minutes"));
unsafe {
env::remove_var("FAUCET_PRIVATE_KEY");
}
}
Comment on lines +679 to +697
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and check its size
wc -l backend/crates/atlas-server/src/config.rs

Repository: evstack/atlas

Length of output: 103


🏁 Script executed:

# Search for ENV_LOCK in the file
rg "ENV_LOCK" backend/crates/atlas-server/src/config.rs -B 2 -A 2

Repository: evstack/atlas

Length of output: 3562


🏁 Script executed:

# Look at the tests module structure and how env is managed
rg "mod tests" backend/crates/atlas-server/src/config.rs -A 50 | head -80

Repository: evstack/atlas

Length of output: 2620


🏁 Script executed:

# Check the specific lines 679-697 and surrounding context to see if ENV_LOCK is used
sed -n '670,710p' backend/crates/atlas-server/src/config.rs

Repository: evstack/atlas

Length of output: 1255


Protect this env-driven test with the shared env lock.

This test writes FAUCET_PRIVATE_KEY directly without acquiring ENV_LOCK. Since ENV_LOCK synchronizes all other environment-variable tests in the tests module below, this unprotected mutation can race and cause nondeterministic failures. Either move ENV_LOCK to a shared location accessible from both tests_from_run_args and tests, or relocate this test to the tests module where the lock is defined.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/crates/atlas-server/src/config.rs` around lines 679 - 697, This test
mutates FAUCET_PRIVATE_KEY without acquiring the shared ENV_LOCK used by other
environment-variable tests; protect the mutation by acquiring the same ENV_LOCK
before setting/removing the env var (or move the test into the tests module that
already defines ENV_LOCK) so the setup/teardown around
FaucetConfig::from_faucet_args is synchronized—e.g., obtain ENV_LOCK (from the
tests module) at the top of faucet_blank_cooldown_is_treated_as_missing, then
set FAUCET_PRIVATE_KEY, call FaucetConfig::from_faucet_args, and remove the var
before dropping the lock.

}

#[cfg(test)]
Expand Down Expand Up @@ -895,6 +985,111 @@ mod tests {
);
}

fn clear_snapshot_env() {
env::remove_var("SNAPSHOT_ENABLED");
env::remove_var("SNAPSHOT_TIME");
env::remove_var("SNAPSHOT_RETENTION");
env::remove_var("SNAPSHOT_DIR");
}

#[test]
fn snapshot_config_defaults_disabled() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();

let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
assert!(!config.enabled);
assert_eq!(config.time, NaiveTime::from_hms_opt(3, 0, 0).unwrap());
assert_eq!(config.retention, 7);
assert_eq!(config.dir, "/snapshots");
}

#[test]
fn snapshot_config_parses_valid_time() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();
env::set_var("SNAPSHOT_ENABLED", "true");

for (input, hour, minute) in [("00:00", 0, 0), ("03:00", 3, 0), ("23:59", 23, 59)] {
env::set_var("SNAPSHOT_TIME", input);
let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
assert_eq!(
config.time,
NaiveTime::from_hms_opt(hour, minute, 0).unwrap(),
"failed for input {input}"
);
}
clear_snapshot_env();
}

#[test]
fn snapshot_config_rejects_invalid_time() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();
env::set_var("SNAPSHOT_ENABLED", "true");

for val in ["25:00", "abc", "12:60"] {
env::set_var("SNAPSHOT_TIME", val);
let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
assert!(
err.to_string().contains("Invalid SNAPSHOT_TIME"),
"expected error for {val}, got: {err}"
);
}
clear_snapshot_env();
}

#[test]
fn snapshot_config_rejects_zero_retention() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();
env::set_var("SNAPSHOT_ENABLED", "true");
env::set_var("SNAPSHOT_RETENTION", "0");

let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
assert!(err.to_string().contains("must be greater than 0"));
clear_snapshot_env();
}

#[test]
fn snapshot_config_custom_dir() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();
env::set_var("SNAPSHOT_ENABLED", "true");
env::set_var("SNAPSHOT_DIR", "/data/backups");

let config = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap();
assert_eq!(config.dir, "/data/backups");
clear_snapshot_env();
}

#[test]
fn snapshot_config_rejects_empty_dir() {
let _lock = ENV_LOCK.lock().unwrap();
clear_snapshot_env();
env::set_var("SNAPSHOT_ENABLED", "true");
env::set_var("SNAPSHOT_DIR", " ");

let err = SnapshotConfig::from_env("postgres://test@localhost/test").unwrap_err();
assert!(err.to_string().contains("SNAPSHOT_DIR must not be empty"));
clear_snapshot_env();
}

#[test]
fn snapshot_config_debug_redacts_database_url() {
let config = SnapshotConfig {
enabled: true,
time: NaiveTime::from_hms_opt(3, 0, 0).unwrap(),
retention: 7,
dir: "/snapshots".to_string(),
database_url: "postgres://atlas:secret@db/atlas".to_string(),
};

let debug = format!("{config:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret"));
}

#[test]
fn faucet_config_rejects_bad_inputs() {
let _lock = ENV_LOCK.lock().unwrap();
Expand Down
Loading
Loading