use std::cell::{Cell, RefCell};
use std::path::PathBuf;
thread_local! {
static STAT_CALLS: Cell<usize> = const { Cell::new(0) };
}
pub(crate) fn note_stat_call() {
let _ = STAT_CALLS.try_with(|c| c.set(c.get() + 1));
}
pub(crate) fn count_stat_calls<T>(f: impl FnOnce() -> T) -> (T, usize) {
let before = STAT_CALLS.with(|c| c.get());
let out = f();
(out, STAT_CALLS.with(|c| c.get()).saturating_sub(before))
}
pub(crate) fn unique_tmp(prefix: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
}
thread_local! {
static CLIPBOARD: RefCell<Option<String>> = const { RefCell::new(None) };
}
pub(crate) fn set_test_clipboard(text: &str) {
CLIPBOARD.with(|c| *c.borrow_mut() = Some(text.to_string()));
}
pub(crate) fn get_test_clipboard() -> Option<String> {
CLIPBOARD.with(|c| c.borrow().clone())
}
pub(crate) fn clear_test_clipboard() {
CLIPBOARD.with(|c| *c.borrow_mut() = None);
}
thread_local! {
static CACHE_ROOT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
pub(crate) fn set_test_cache_root(path: PathBuf) {
CACHE_ROOT.with(|c| *c.borrow_mut() = Some(path));
}
pub(crate) fn get_test_cache_root() -> Option<PathBuf> {
CACHE_ROOT.with(|c| c.borrow().clone())
}
thread_local! {
static TRASHED: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn record_trashed(path: PathBuf) {
TRASHED.with(|t| t.borrow_mut().push(path));
}
pub(crate) fn get_trashed() -> Vec<PathBuf> {
TRASHED.with(|t| t.borrow().clone())
}
pub(crate) fn clear_test_trashed() {
TRASHED.with(|t| t.borrow_mut().clear());
}
#[cfg(test)]
mod guard {
use std::path::{Path, PathBuf};
const EXEMPT_FILES: &[&str] = &[
"test_support.rs",
"preview/pdf.rs",
"preview/command.rs",
"preview/video.rs",
];
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
const PATH_BUILDERS: [&str; 2] = [".join(", ".push("];
const FS_MUTATORS: [&str; 8] = [
"create_dir_all(",
"create_dir(",
"remove_dir_all(",
"remove_file(",
"File::create(",
"fs::write(",
"OpenOptions::new(",
"set_current_dir(",
];
fn contains_any(line: &str, needles: &[&str]) -> bool {
needles.iter().any(|n| line.contains(n))
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn contains_word(line: &str, word: &str) -> bool {
if word.is_empty() {
return false;
}
let bytes = line.as_bytes();
let mut start = 0;
while let Some(rel) = line[start..].find(word) {
let at = start + rel;
let before_ok = at == 0 || !is_ident_byte(bytes[at - 1]);
let after = at + word.len();
let after_ok = after >= bytes.len() || !is_ident_byte(bytes[after]);
if before_ok && after_ok {
return true;
}
start = at + 1;
}
false
}
fn line_targets_a_path(line: &str) -> bool {
contains_any(line, &PATH_BUILDERS) || contains_any(line, &FS_MUTATORS)
}
fn line_uses_ident_as_path(line: &str, ident: &str) -> bool {
PATH_BUILDERS
.iter()
.any(|m| line.contains(&format!("{ident}{m}")))
|| (contains_any(line, &FS_MUTATORS) && contains_word(line, ident))
}
fn let_binding_ident(line: &str) -> Option<&str> {
let after_let = line.find("let ")?;
let mut rest = line[after_let + 4..].trim_start();
rest = rest.strip_prefix("mut ").unwrap_or(rest).trim_start();
let end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if end == 0 {
None
} else {
Some(&rest[..end])
}
}
fn is_fn_start(line: &str) -> bool {
let mut t = line.trim_start();
for prefix in ["pub(crate) ", "pub ", "async ", "unsafe "] {
if let Some(s) = t.strip_prefix(prefix) {
t = s;
}
}
t.starts_with("fn ")
}
const IDENT_TRACE_WINDOW: usize = 200;
const UNIQUENESS_PROOF: &str = "process::id()";
const PROOF_WINDOW: usize = 6;
fn has_nearby_proof(lines: &[&str], at: usize) -> bool {
let end = (at + PROOF_WINDOW).min(lines.len());
lines[at..end].iter().any(|l| l.contains(UNIQUENESS_PROOF))
}
fn line_has_risky_base(line: &str) -> bool {
line.contains("temp_dir()") || line.contains("TMPDIR") || line.contains("\"/tmp/")
}
fn find_offenders(src_dir: &Path) -> (usize, Vec<String>) {
let mut rs_files = Vec::new();
collect_rs_files(src_dir, &mut rs_files);
let mut files_scanned = 0usize;
let mut offenders = Vec::new();
for path in &rs_files {
let rel = path
.strip_prefix(src_dir)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if EXEMPT_FILES.iter().any(|f| rel == *f) {
continue;
}
files_scanned += 1;
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
if !line_has_risky_base(line) {
continue;
}
if line_targets_a_path(line) {
if !has_nearby_proof(&lines, i) {
offenders.push(format!("{rel}:{}: {}", i + 1, line.trim()));
}
continue; }
let Some(ident) = let_binding_ident(line) else {
continue;
};
let mut trace_end = (i + 1 + IDENT_TRACE_WINDOW).min(lines.len());
for (j, l) in lines.iter().enumerate().take(trace_end).skip(i + 1) {
if is_fn_start(l) {
trace_end = j;
break;
}
}
for (j, l) in lines.iter().enumerate().take(trace_end).skip(i + 1) {
if line_uses_ident_as_path(l, ident) && !has_nearby_proof(&lines, j) {
offenders.push(format!(
"{rel}:{}: {} (bound at {}: {})",
j + 1,
l.trim(),
i + 1,
line.trim()
));
break; }
}
}
}
(files_scanned, offenders)
}
#[test]
fn scan_finds_at_least_20_source_files() {
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let (files_scanned, _) = find_offenders(&src_dir);
assert!(
files_scanned >= 20,
"スキャン対象が少なすぎる(安全弁): {files_scanned} 件 — src/ の探索が壊れている可能性"
);
}
#[test]
fn no_fixed_name_temp_dirs_outside_unique_tmp() {
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let (files_scanned, offenders) = find_offenders(&src_dir);
assert!(
files_scanned >= 20,
"スキャン対象が少なすぎる(安全弁): {files_scanned} 件"
);
assert!(
offenders.is_empty(),
"共有ヘルパー unique_tmp を経由しない固定名 temp_dir() 呼び出しを検出\n\
(固定名は並行実行中の2プロセスが衝突する — このモジュールの docコメント参照):\n{}",
offenders.join("\n")
);
}
}