use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use freenet_stdlib::prelude::*;
use crate::contract::storages::Storage;
use super::quota::{MAX_RECLAIMS_PER_SWEEP, SWEEP_MAX_GAP_INTERVAL_MULTIPLE, USER_QUOTA_TRACKER};
use super::user::UserId;
pub(super) const USERS_DIR: &str = "users";
pub(super) const LAST_SEEN_FILE: &str = ".last_seen";
pub(crate) fn create_owner_only(path: &Path) -> std::io::Result<std::fs::File> {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path)
}
#[cfg(unix)]
pub(crate) fn ensure_owner_only_dir(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
let mode = perms.mode() & 0o777;
if mode != 0o700 {
tracing::warn!(
path = %path.display(),
existing_mode = format_args!("{mode:o}"),
"secrets directory was not 0o700; tightening to owner-only"
);
perms.set_mode(0o700);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn ensure_owner_only_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
pub(super) fn ensure_owner_only_tree(base: &Path, full: &Path) -> std::io::Result<()> {
let Ok(rel) = full.strip_prefix(base) else {
return ensure_owner_only_dir(full);
};
let mut current = base.to_path_buf();
for component in rel.components() {
current.push(component);
ensure_owner_only_dir(¤t)?;
}
Ok(())
}
pub(super) fn decode_bs58_32(name: &str) -> Option<[u8; 32]> {
let mut out = [0u8; 32];
match bs58::decode(name)
.with_alphabet(bs58::Alphabet::BITCOIN)
.onto(&mut out)
{
Ok(32) => Some(out),
_ => None,
}
}
pub(super) fn user_activity_dir(base_path: &Path, user_id: &UserId) -> PathBuf {
base_path.join(USERS_DIR).join(user_id.encode())
}
pub fn wall_clock_unix_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(super) fn read_user_last_seen(base_path: &Path, user_id: &UserId) -> Option<u64> {
let path = user_activity_dir(base_path, user_id).join(LAST_SEEN_FILE);
let raw = fs::read_to_string(&path).ok()?;
raw.trim().parse::<u64>().ok()
}
pub fn stamp_user_last_seen(
base_path: &Path,
user_id: &UserId,
now: u64,
debounce_secs: u64,
) -> bool {
if let Some(prev) = read_user_last_seen(base_path, user_id) {
if now.saturating_sub(prev) < debounce_secs {
return false;
}
}
let dir = user_activity_dir(base_path, user_id);
if let Err(e) = fs::create_dir_all(&dir) {
tracing::debug!(user_id = %user_id.encode(), error = %e, "last_seen: mkdir failed");
return false;
}
ensure_owner_only_tree(base_path, &dir).ok();
let tmp = dir.join(format!("{LAST_SEEN_FILE}.tmp"));
let final_path = dir.join(LAST_SEEN_FILE);
let write_then_rename = || -> std::io::Result<()> {
fs::write(&tmp, now.to_string())?;
fs::rename(&tmp, &final_path)
};
match write_then_rename() {
Ok(()) => true,
Err(e) => {
tracing::debug!(user_id = %user_id.encode(), error = %e, "last_seen: write failed");
fs::remove_file(&tmp).ok();
false
}
}
}
pub(super) fn enumerate_marked_users(base_path: &Path) -> Vec<UserId> {
let users_root = base_path.join(USERS_DIR);
let rd = match fs::read_dir(&users_root) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(error = %e, dir = %users_root.display(), "inactive-user sweep: cannot read users dir");
return Vec::new();
}
};
let mut out = Vec::new();
for entry in rd.flatten() {
if !entry.path().is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if let Some(bytes) = decode_bs58_32(&name) {
out.push(UserId::new(bytes));
}
}
out
}
fn enumerate_delegate_keys(base_path: &Path) -> Vec<[u8; 32]> {
let rd = match fs::read_dir(base_path) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for entry in rd.flatten() {
if !entry.path().is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if name == USERS_DIR {
continue;
}
if let Some(bytes) = decode_bs58_32(&name) {
out.push(bytes);
}
}
out
}
#[cfg(test)]
pub fn reclaim_user(base_path: &Path, db: &Storage, user_id: &UserId) {
let delegate_keys = group_index_rows_by_user(db)
.and_then(|mut m| m.remove(user_id))
.unwrap_or_default();
reclaim_user_with_index_rows(base_path, db, user_id, &delegate_keys);
}
fn reclaim_user_with_index_rows(
base_path: &Path,
db: &Storage,
user_id: &UserId,
index_delegate_keys: &[DelegateKey],
) {
for key_bytes in enumerate_delegate_keys(base_path) {
let user_dir = base_path
.join(DelegateKey::new(key_bytes, CodeHash::from(&[0u8; 32])).encode())
.join(USERS_DIR)
.join(user_id.encode());
match fs::remove_dir_all(&user_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
user_id = %user_id.encode(),
dir = %user_dir.display(),
error = %e,
"inactive-user reclaim: failed to remove user secret dir (will retry next sweep)"
);
}
}
}
remove_user_index_rows(db, user_id, index_delegate_keys);
let marker_dir = user_activity_dir(base_path, user_id);
match fs::remove_dir_all(&marker_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(
user_id = %user_id.encode(),
dir = %marker_dir.display(),
error = %e,
"inactive-user reclaim: failed to remove activity-marker dir (will retry next sweep)"
);
}
}
USER_QUOTA_TRACKER
.per_user_bytes
.remove(&(base_path.to_path_buf(), *user_id));
}
fn remove_user_index_rows(db: &Storage, user_id: &UserId, delegate_keys: &[DelegateKey]) {
for delegate_key in delegate_keys {
if let Err(e) = db.remove_user_secrets_index(delegate_key, user_id.as_bytes()) {
tracing::warn!(
user_id = %user_id.encode(),
error = %e,
"inactive-user reclaim: failed to remove ReDb index row (will retry next sweep)"
);
}
}
}
fn group_index_rows_by_user(
db: &Storage,
) -> Option<std::collections::HashMap<UserId, Vec<DelegateKey>>> {
let rows = match db.load_all_user_secrets_index() {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(
error = %e,
"inactive-user sweep: cannot read user-secrets index (will retry next sweep)"
);
return None;
}
};
let mut by_user: std::collections::HashMap<UserId, Vec<DelegateKey>> =
std::collections::HashMap::new();
for ((delegate_key, uid_bytes), _secrets) in rows {
by_user
.entry(UserId::new(uid_bytes))
.or_default()
.push(delegate_key);
}
Some(by_user)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SweepOutcome {
pub reclaimed: usize,
pub capped: bool,
pub skipped_gap: bool,
}
pub fn reclaim_inactive_users(
base_path: &Path,
db: &Storage,
now: u64,
ttl: u64,
prev_sweep_now: Option<u64>,
max_gap_secs: u64,
max_reclaims: usize,
) -> SweepOutcome {
if ttl == 0 {
return SweepOutcome::default();
}
if let Some(prev) = prev_sweep_now {
if max_gap_secs > 0 && now.saturating_sub(prev) > max_gap_secs {
tracing::warn!(
now,
prev_sweep_now = prev,
gap_secs = now.saturating_sub(prev),
max_gap_secs,
"inactive-user sweep: time advanced implausibly far since the last \
sweep (suspected forward clock jump or long suspend) — SKIPPING \
reclaim this pass to avoid a clock-fault mass-delete; will \
re-evaluate next pass"
);
return SweepOutcome {
reclaimed: 0,
capped: false,
skipped_gap: true,
};
}
}
let mut reclaimed = 0usize;
let mut capped = false;
let Some(mut index_by_user) = group_index_rows_by_user(db) else {
return SweepOutcome::default();
};
for user_id in enumerate_marked_users(base_path) {
if max_reclaims > 0 && reclaimed >= max_reclaims {
capped = true;
break;
}
let Some(last_seen) = read_user_last_seen(base_path, &user_id) else {
continue;
};
if now.saturating_sub(last_seen) > ttl {
tracing::info!(
user_id = %user_id.encode(),
idle_secs = now.saturating_sub(last_seen),
ttl_secs = ttl,
"inactive-user sweep: reclaiming abandoned hosted user"
);
let delegate_keys = index_by_user.remove(&user_id).unwrap_or_default();
reclaim_user_with_index_rows(base_path, db, &user_id, &delegate_keys);
reclaimed += 1;
}
}
if capped {
tracing::warn!(
reclaimed,
cap = max_reclaims,
"inactive-user sweep hit reclaim cap {max_reclaims} — possible clock \
fault or large backlog; remaining users deferred to next sweep"
);
}
SweepOutcome {
reclaimed,
capped,
skipped_gap: false,
}
}
pub fn should_spawn_inactive_user_sweep(hosted_mode: bool, ttl_secs: u64) -> bool {
hosted_mode && ttl_secs > 0
}
#[must_use = "the returned JoinHandle must be retained so the sweep is aborted on shutdown"]
pub fn spawn_inactive_user_sweep(
base_path: PathBuf,
db: Storage,
ttl_secs: u64,
sweep_interval_secs: u64,
) -> Option<tokio::task::JoinHandle<()>> {
if ttl_secs == 0 {
return None;
}
let interval = std::time::Duration::from_secs(sweep_interval_secs.max(1));
let max_gap_secs = interval
.as_secs()
.saturating_mul(SWEEP_MAX_GAP_INTERVAL_MULTIPLE);
let handle = crate::config::GlobalExecutor::spawn(async move {
tracing::info!(
ttl_secs,
sweep_interval_secs = interval.as_secs(),
max_gap_secs,
max_reclaims_per_sweep = MAX_RECLAIMS_PER_SWEEP,
"Inactive-user reclaim sweep started (hosted mode)"
);
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
let mut prev_sweep_now: Option<u64> = None;
loop {
ticker.tick().await;
let now = wall_clock_unix_secs();
let base = base_path.clone();
let storage = db.clone();
let result = tokio::task::spawn_blocking(move || {
reclaim_inactive_users(
&base,
&storage,
now,
ttl_secs,
prev_sweep_now,
max_gap_secs,
MAX_RECLAIMS_PER_SWEEP,
)
})
.await;
let ran = matches!(&result, Ok(o) if !o.skipped_gap);
if ran {
prev_sweep_now = Some(now);
}
match result {
Ok(o) => {
if o.reclaimed > 0 {
tracing::info!(
reclaimed = o.reclaimed,
"inactive-user sweep: reclaimed abandoned users"
);
}
}
Err(e) => {
tracing::warn!(error = %e, "inactive-user sweep pass panicked/cancelled");
}
}
}
});
Some(handle)
}