use base64::Engine;
use hmac::{Hmac, Mac};
use rand::{rngs::OsRng, RngCore};
use sha2::Sha256;
#[derive(Debug)]
pub enum SessionSecretError {
Missing,
BadBase64 { cause: String },
TooShort { actual: usize },
}
impl core::fmt::Display for SessionSecretError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Missing => write!(
f,
"RUSTANGO_SESSION_SECRET is not set \
(generate one with: openssl rand -base64 32)"
),
Self::BadBase64 { cause } => write!(
f,
"RUSTANGO_SESSION_SECRET is not valid base64: {cause} \
(generate one with: openssl rand -base64 32)"
),
Self::TooShort { actual } => write!(
f,
"RUSTANGO_SESSION_SECRET decoded to {actual} bytes; need at least 32 \
(generate one with: openssl rand -base64 32)"
),
}
}
}
impl std::error::Error for SessionSecretError {}
#[cfg(feature = "tenancy")]
pub(crate) const TENANT_SESSION_KEY_PATH: &str = "./var/.rustango_tenant_session.key";
#[cfg(feature = "tenancy")]
pub(crate) const OPERATOR_SESSION_KEY_PATH: &str = "./var/.rustango_operator_session.key";
#[derive(Clone)]
pub struct SessionSecret(Vec<u8>);
impl SessionSecret {
#[must_use]
pub fn from_env_or_random() -> Self {
if let Ok(raw) = std::env::var("RUSTANGO_SESSION_SECRET") {
match Self::from_b64(&raw) {
Ok(secret) => return secret,
Err(e) => {
tracing::warn!(error = %e, "RUSTANGO_SESSION_SECRET unusable — falling back to random");
eprintln!(
"\x1b[33;1mwarning:\x1b[0m {e}. Using a random key. \
Sessions will NOT survive a server restart.",
);
}
}
} else {
tracing::warn!(
"RUSTANGO_SESSION_SECRET not set — generating random key (sessions \
will not survive server restarts; set the env var for production)",
);
}
let mut buf = vec![0u8; 32];
OsRng.fill_bytes(&mut buf);
Self(buf)
}
#[must_use]
pub fn from_env_or_disk(disk_path: &std::path::Path) -> Self {
Self::from_raw_or_disk(
std::env::var("RUSTANGO_SESSION_SECRET").ok().as_deref(),
disk_path,
)
}
pub(crate) fn from_raw_or_disk(raw: Option<&str>, disk_path: &std::path::Path) -> Self {
if let Some(raw) = raw {
match Self::from_b64(raw) {
Ok(secret) => return secret,
Err(e) => warn_unusable_secret(&e.to_string()),
}
}
match load_or_create_key_file(disk_path, "session secret", "RUSTANGO_SESSION_SECRET") {
Ok(bytes) => Self(bytes),
Err(e) => {
tracing::warn!(
path = %disk_path.display(),
error = %e,
"could not persist session secret to disk — using ephemeral random key",
);
let mut buf = vec![0u8; 32];
OsRng.fill_bytes(&mut buf);
Self(buf)
}
}
}
pub fn try_from_env() -> Result<Self, SessionSecretError> {
if let Ok(raw) = std::env::var("RUSTANGO_SESSION_SECRET") {
return Self::from_b64(&raw);
}
tracing::warn!(
"RUSTANGO_SESSION_SECRET not set — generating random key (sessions \
will not survive server restarts; set the env var for production)",
);
let mut buf = vec![0u8; 32];
OsRng.fill_bytes(&mut buf);
Ok(Self(buf))
}
pub fn require_from_env() -> Result<Self, SessionSecretError> {
match std::env::var("RUSTANGO_SESSION_SECRET") {
Ok(raw) => Self::from_b64(&raw),
Err(_) => Err(SessionSecretError::Missing),
}
}
pub fn from_b64(raw: &str) -> Result<Self, SessionSecretError> {
match base64::engine::general_purpose::STANDARD.decode(raw.trim()) {
Ok(bytes) if bytes.len() >= 32 => Ok(Self(bytes)),
Ok(bytes) => Err(SessionSecretError::TooShort {
actual: bytes.len(),
}),
Err(e) => Err(SessionSecretError::BadBase64 {
cause: e.to_string(),
}),
}
}
#[must_use]
pub fn from_bytes(bytes: Vec<u8>) -> Self {
assert!(
bytes.len() >= 32,
"SessionSecret is too short; need >= 32 bytes (a shorter key is forgeable)"
);
Self(bytes)
}
pub(crate) fn key(&self) -> &[u8] {
&self.0
}
}
#[must_use]
pub fn is_prod_tier(tier: &str) -> bool {
crate::env::is_prod_tier(tier)
}
#[must_use]
pub fn load_session_secret_for_tier(tier: &str, disk_path: &std::path::Path) -> SessionSecret {
if is_prod_tier(tier) {
match SessionSecret::require_from_env() {
Ok(secret) => secret,
Err(e) => panic!(
"refusing to start on the prod tier (RUSTANGO_ENV={tier}): {e}. \
Set RUSTANGO_SESSION_SECRET to a stable base64-encoded 32+ byte \
key shared across all instances."
),
}
} else {
SessionSecret::from_env_or_disk(disk_path)
}
}
#[must_use]
pub fn tier_from_env() -> String {
crate::env::tier_from_env()
}
static SECURE_COOKIES_OVERRIDE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
pub fn set_secure_cookies(secure: bool) -> bool {
SECURE_COOKIES_OVERRIDE.set(secure).is_ok()
}
fn resolve_secure_cookies(override_flag: Option<bool>, tier: &str) -> bool {
override_flag.unwrap_or_else(|| is_prod_tier(tier))
}
#[must_use]
pub fn secure_cookies() -> bool {
resolve_secure_cookies(SECURE_COOKIES_OVERRIDE.get().copied(), &tier_from_env())
}
pub(crate) fn warn_unusable_secret(reason: &str) {
tracing::warn!(
reason,
"RUSTANGO_SESSION_SECRET is set but unusable — falling back to the persisted key",
);
eprintln!(
"\x1b[33;1mwarning:\x1b[0m RUSTANGO_SESSION_SECRET is set but cannot be used: \
{reason}. Falling back to the key on disk. Generate a valid one with: \
openssl rand -base64 32"
);
}
pub(crate) fn load_or_create_key_file(
path: &std::path::Path,
what: &str,
var: &str,
) -> std::io::Result<Vec<u8>> {
match std::fs::read(path) {
Ok(bytes) if bytes.len() >= 32 => {
restrict_session_secret_perms(path);
tracing::info!(path = %path.display(), "loaded persisted {what} from disk");
return Ok(bytes);
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut buf = vec![0u8; 32];
OsRng.fill_bytes(&mut buf);
if let Some(parent) = path.parent() {
create_private_dir(parent);
}
let on_disk = persist_secret(path, &buf)?;
tracing::info!(
path = %path.display(),
"generated new {what} and persisted to disk (set {var} to override; \
this message only fires on first boot)",
);
Ok(on_disk)
}
fn persist_secret(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::Write as _;
let mut nonce = [0u8; 8];
OsRng.fill_bytes(&mut nonce);
let suffix: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(format!(".{}.{suffix}.tmp", std::process::id()));
let tmp = path.with_file_name(tmp_name);
let mut file = create_owner_only(&tmp)?;
let published = file
.write_all(bytes)
.and_then(|()| file.sync_all())
.and_then(|()| publish_first(&tmp, path));
let _ = std::fs::remove_file(&tmp);
let won = published?;
sync_parent(path);
if won {
return Ok(bytes.to_vec());
}
let on_disk = std::fs::read(path)?;
if on_disk.len() >= 32 {
return Ok(on_disk);
}
Err(std::io::Error::other("session secret file is too short"))
}
fn publish_first(tmp: &std::path::Path, path: &std::path::Path) -> std::io::Result<bool> {
match std::fs::hard_link(tmp, path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
match std::fs::read(path)? {
b if b.len() >= 32 => Ok(false),
_ => std::fs::rename(tmp, path).map(|()| true),
}
}
Err(_) if std::fs::symlink_metadata(path).is_ok() => Ok(false),
Err(_) => std::fs::rename(tmp, path).map(|()| true),
}
}
fn create_private_dir(dir: &std::path::Path) {
let mut b = std::fs::DirBuilder::new();
b.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
let _ = b.create(dir);
}
fn sync_parent(path: &std::path::Path) {
#[cfg(unix)]
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
let _ = std::fs::File::open(dir).and_then(|d| d.sync_all());
}
#[cfg(not(unix))]
let _ = path;
}
fn create_owner_only(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
opts.open(path)
}
#[cfg(unix)]
fn restrict_session_secret_perms(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::symlink_metadata(path) else {
return;
};
if meta.file_type().is_symlink() {
let loose = std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o077 != 0);
if loose {
tracing::warn!(
path = %path.display(),
"session secret link points to a file other users can read; not changed. \
Restrict it and rotate the key",
);
}
return;
}
let mut perms = meta.permissions();
if perms.mode() & 0o077 == 0 {
return;
}
tracing::warn!(
path = %path.display(),
mode = format!("{:o}", perms.mode() & 0o777),
"session secret file was readable by other users; setting it to 0600. \
Rotate the key: it may have been read",
);
perms.set_mode(0o600);
let _ = std::fs::set_permissions(path, perms);
}
#[cfg(not(unix))]
fn restrict_session_secret_perms(_path: &std::path::Path) {
}
#[must_use]
pub fn sign(secret: &SessionSecret, msg: &[u8]) -> [u8; 32] {
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.key()).expect("HMAC accepts any key length");
mac.update(msg);
let bytes = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&bytes[..32]);
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct PasswordFingerprint(String);
const PASSWORD_FINGERPRINT_TAG: &[u8] = b"rustango-pwf-v1.";
impl PasswordFingerprint {
#[must_use]
pub fn of(secret: &SessionSecret, password_hash: &str) -> Self {
let msg = [PASSWORD_FINGERPRINT_TAG, password_hash.as_bytes()].concat();
Self(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sign(secret, &msg)))
}
#[must_use]
pub fn matches(&self, secret: &SessionSecret, password_hash: &str) -> bool {
let current = Self::of(secret, password_hash);
use subtle::ConstantTimeEq as _;
self.0.as_bytes().ct_eq(current.0.as_bytes()).into()
}
}
#[cfg(all(feature = "totp", any(feature = "admin-sso", feature = "tenancy")))]
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct PendingSecondFactor {
pub(crate) uid: i64,
pub(crate) pwf: PasswordFingerprint,
pub(crate) exp: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) upgrade: Option<String>,
}
#[cfg(all(feature = "totp", any(feature = "admin-sso", feature = "tenancy")))]
impl PendingSecondFactor {
pub(crate) const TTL_SECS: i64 = 300;
pub(crate) fn new(secret: &SessionSecret, uid: i64, password_hash: &str) -> Self {
Self {
uid,
pwf: PasswordFingerprint::of(secret, password_hash),
exp: chrono::Utc::now().timestamp() + Self::TTL_SECS,
upgrade: None,
}
}
fn mac(secret: &SessionSecret, tag: &[u8], body: &str) -> [u8; 32] {
sign(secret, &[tag, body.as_bytes()].concat())
}
pub(crate) fn seal(&self, secret: &SessionSecret, tag: &[u8]) -> String {
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let body = b64.encode(serde_json::to_vec(self).unwrap_or_default());
let sig = b64.encode(Self::mac(secret, tag, &body));
format!("{body}.{sig}")
}
pub(crate) fn open(secret: &SessionSecret, tag: &[u8], value: &str) -> Option<Self> {
use subtle::ConstantTimeEq as _;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let (body, sig) = value.split_once('.')?;
let sig = b64.decode(sig).ok()?;
if Self::mac(secret, tag, body).ct_eq(&sig[..]).unwrap_u8() == 0 {
return None;
}
let p: Self = serde_json::from_slice(&b64.decode(body).ok()?).ok()?;
(chrono::Utc::now().timestamp() < p.exp).then_some(p)
}
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
pub(crate) const SESSIONS_REVOKED_AT: &str = "sessions_revoked_at";
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[must_use]
pub(crate) fn session_survives(
secret: &SessionSecret,
pwf: &PasswordFingerprint,
iat: i64,
password_hash: &str,
password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
) -> bool {
pwf.matches(secret, password_hash)
&& password_changed_at.is_none_or(|ts| iat >= ts.timestamp())
&& sessions_revoked_at.is_none_or(|ts| iat > ts.timestamp())
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[must_use]
pub(crate) fn issued_at(sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>) -> i64 {
let now = chrono::Utc::now().timestamp();
sessions_revoked_at.map_or(now, |ts| now.max(ts.timestamp() + 1))
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[must_use]
pub(crate) fn logout_cutoff(
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
cookie_iat: i64,
) -> i64 {
issued_at(sessions_revoked_at).max(cookie_iat.saturating_add(1))
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[must_use]
pub(crate) fn logout_cutoff_at(
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
cookie_iat: i64,
) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::from_timestamp(logout_cutoff(sessions_revoked_at, cookie_iat), 0)
.unwrap_or_else(chrono::Utc::now)
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
pub(crate) async fn revoke_sessions<T: crate::core::Model + Send>(
pool: &crate::sql::Pool,
id: i64,
sessions_revoked_at: Option<chrono::DateTime<chrono::Utc>>,
cookie_iat: i64,
) -> Result<u64, crate::sql::ExecError> {
use crate::query::Q;
use crate::sql::UpdaterPool as _;
let cutoff = logout_cutoff_at(sessions_revoked_at, cookie_iat);
crate::query::QuerySet::<T>::default()
.filter("id", id)
.where_(Q::is_null(SESSIONS_REVOKED_AT) | Q::lt(SESSIONS_REVOKED_AT, cutoff))
.update()
.set(SESSIONS_REVOKED_AT, cutoff)
.execute_pool(pool)
.await
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
pub async fn deactivate<T: crate::core::Model + Send>(
pool: &crate::sql::Pool,
id: i64,
) -> Result<bool, crate::sql::ExecError> {
use crate::core::{SelectQuery, SqlValue};
use crate::sql::UpdaterPool as _;
let fields: Vec<&'static crate::core::FieldSchema> = T::SCHEMA
.fields
.iter()
.filter(|f| f.column == SESSIONS_REVOKED_AT)
.collect();
let select = SelectQuery::by_pk(T::SCHEMA, "id", SqlValue::I64(id));
let Some(row) = crate::sql::select_one_row_as_json(pool, &select, &fields).await? else {
return Ok(false);
};
let prev = row
.get(SESSIONS_REVOKED_AT)
.and_then(serde_json::Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
revoke_sessions::<T>(pool, id, prev, 0).await?;
crate::query::QuerySet::<T>::default()
.filter("id", id)
.update()
.set("active", false)
.execute_pool(pool)
.await?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn an_unreadable_key_file_is_kept() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("key");
std::fs::write(&path, [9u8; 32]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&path).is_ok() {
return; }
let got = load_or_create_key_file(&path, "test key", "TEST_KEY");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(got.is_err(), "{got:?}");
assert_eq!(std::fs::read(&path).unwrap(), [9u8; 32]);
}
#[test]
fn a_password_fingerprint_is_not_a_bare_mac_of_the_hash() {
let secret = SessionSecret::from_bytes(vec![7u8; 32]);
let bare =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sign(&secret, b"$argon2id$h"));
assert_ne!(PasswordFingerprint::of(&secret, "$argon2id$h").0, bare);
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[test]
fn a_logout_cutoff_ends_older_sessions_but_not_the_next_login() {
let secret = SessionSecret::from_bytes(vec![7u8; 32]);
let pwf = PasswordFingerprint::of(&secret, "$h");
let now = chrono::Utc::now().timestamp();
let cut = chrono::DateTime::from_timestamp(now, 0);
assert!(session_survives(&secret, &pwf, now, "$h", None, None));
assert!(!session_survives(&secret, &pwf, now, "$h", None, cut));
let next = issued_at(cut);
assert!(next > now);
assert!(session_survives(&secret, &pwf, next, "$h", None, cut));
}
#[cfg(any(feature = "admin", feature = "tenancy"))]
#[test]
fn a_logout_cutoff_covers_a_cookie_from_a_faster_clock() {
let ahead = chrono::Utc::now().timestamp() + 100;
assert_eq!(logout_cutoff(None, ahead), ahead + 1);
let later = chrono::DateTime::from_timestamp(ahead + 50, 0);
assert_eq!(logout_cutoff(later, ahead), ahead + 51);
}
#[test]
#[should_panic(expected = "need >= 32")]
fn a_short_session_secret_is_refused() {
let _ = SessionSecret::from_bytes(b"too-short".to_vec());
}
#[test]
fn a_thirty_two_byte_secret_is_accepted() {
let s = SessionSecret::from_bytes(vec![0u8; 32]);
assert_eq!(s.key().len(), 32);
}
#[test]
fn sign_is_deterministic_per_key() {
let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
let a = sign(&secret, b"hello");
let b = sign(&secret, b"hello");
assert_eq!(a, b);
}
#[test]
fn sign_differs_across_keys() {
let s1 = SessionSecret::from_bytes(vec![1u8; 32]);
let s2 = SessionSecret::from_bytes(vec![2u8; 32]);
assert_ne!(sign(&s1, b"x"), sign(&s2, b"x"));
}
#[test]
fn from_bytes_round_trip() {
let secret = SessionSecret::from_bytes(vec![0xab; 40]);
assert_eq!(secret.key().len(), 40);
}
#[test]
fn from_b64_accepts_valid_32_byte_key() {
let raw = base64::engine::general_purpose::STANDARD.encode([7u8; 32]);
assert!(SessionSecret::from_b64(&raw).is_ok());
}
#[test]
fn from_b64_rejects_short_key() {
let raw = base64::engine::general_purpose::STANDARD.encode([7u8; 16]);
assert!(matches!(
SessionSecret::from_b64(&raw),
Err(SessionSecretError::TooShort { actual: 16 })
));
}
#[test]
fn from_b64_rejects_bad_base64() {
assert!(matches!(
SessionSecret::from_b64("!!! not base64 !!!"),
Err(SessionSecretError::BadBase64 { .. })
));
}
#[test]
fn is_prod_tier_matches_prod_and_production_case_insensitively() {
assert!(is_prod_tier("prod"));
assert!(is_prod_tier("production"));
assert!(is_prod_tier("PROD"));
assert!(is_prod_tier(" Production "));
assert!(!is_prod_tier("dev"));
assert!(!is_prod_tier("staging"));
assert!(!is_prod_tier(""));
}
#[test]
fn resolve_secure_cookies_override_wins_else_tier() {
assert!(resolve_secure_cookies(Some(true), "dev")); assert!(!resolve_secure_cookies(Some(false), "prod")); assert!(resolve_secure_cookies(None, "prod")); assert!(!resolve_secure_cookies(None, "dev")); assert!(!resolve_secure_cookies(None, "")); }
#[test]
fn dev_tier_loads_without_requiring_env() {
let dir = std::env::temp_dir().join(format!("rustango_sess_test_{}", std::process::id()));
let path = dir.join("k.key");
let s = load_session_secret_for_tier("dev", &path);
assert!(s.key().len() >= 32);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
fn mode(path: &std::path::Path) -> u32 {
use std::os::unix::fs::PermissionsExt as _;
std::fs::symlink_metadata(path)
.unwrap()
.permissions()
.mode()
& 0o777
}
#[cfg(unix)]
#[test]
fn persisting_a_secret_never_writes_through_a_planted_link() {
assert!(std::env::var_os("RUSTANGO_SESSION_SECRET").is_none());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.key");
let victim = dir.path().join("victim");
std::fs::write(&victim, b"").unwrap();
std::os::unix::fs::symlink(&victim, &path).unwrap();
let s = SessionSecret::from_env_or_disk(&path);
assert!(std::fs::read(&victim).unwrap().is_empty(), "key leaked");
assert!(std::fs::symlink_metadata(&path).unwrap().is_file());
assert_eq!(std::fs::read(&path).unwrap(), s.key());
assert_eq!(mode(&path), 0o600);
}
#[cfg(unix)]
#[test]
fn a_loose_saved_secret_is_made_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
assert!(std::env::var_os("RUSTANGO_SESSION_SECRET").is_none());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.key");
std::fs::write(&path, [9u8; 32]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let s = SessionSecret::from_env_or_disk(&path);
assert_eq!(s.key(), [9u8; 32]);
assert_eq!(mode(&path), 0o600);
}
#[test]
fn concurrent_first_boots_share_one_key() {
assert!(std::env::var_os("RUSTANGO_SESSION_SECRET").is_none());
for _ in 0..20 {
let dir = tempfile::tempdir().unwrap();
let path = std::sync::Arc::new(dir.path().join("var").join("s.key"));
let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
let keys: Vec<Vec<u8>> = (0..8)
.map(|_| {
let (path, barrier) = (path.clone(), barrier.clone());
std::thread::spawn(move || {
barrier.wait();
SessionSecret::from_env_or_disk(&path).key().to_vec()
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|h| h.join().unwrap())
.collect();
let on_disk = std::fs::read(&*path).unwrap();
assert!(keys.iter().all(|k| *k == on_disk), "boots diverged");
let left: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(left.len(), 1, "temp files left: {left:?}");
#[cfg(unix)]
assert_eq!(mode(path.parent().unwrap()), 0o700);
}
}
#[cfg(unix)]
#[test]
fn a_linked_secret_is_not_chmoded() {
use std::os::unix::fs::PermissionsExt as _;
assert!(std::env::var_os("RUSTANGO_SESSION_SECRET").is_none());
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("mounted");
std::fs::write(&target, [5u8; 32]).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
let path = dir.path().join("s.key");
std::os::unix::fs::symlink(&target, &path).unwrap();
assert_eq!(SessionSecret::from_env_or_disk(&path).key(), [5u8; 32]);
assert_eq!(mode(&target), 0o644);
}
#[test]
fn a_new_secret_file_holds_the_key() {
assert!(std::env::var_os("RUSTANGO_SESSION_SECRET").is_none());
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("s.key");
let s = SessionSecret::from_env_or_disk(&path);
assert_eq!(std::fs::read(&path).unwrap(), s.key());
assert!(!path.with_extension("tmp").exists());
#[cfg(unix)]
assert_eq!(mode(&path), 0o600);
assert_eq!(SessionSecret::from_env_or_disk(&path).key(), s.key());
}
}