mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-08-08 10:22:00 +00:00
fix(mmf): fall back to Local\ namespace when controller is not elevated
Creating a `Global\TimeMocker_<pid>` mapping requires `SeCreateGlobalPrivilege`, which an unelevated token (e.g. debug `cargo run` where the UAC manifest is intentionally skipped) does not have, so `CreateFileMappingW` returned NULL with `ERROR_ACCESS_DENIED` and the auto-inject scanner spammed `create MMF Global\TimeMocker_<pid>` for every enumerated PID. The UI now tries `Global\` first and falls back to `Local\` on access denied, with a one-shot log warning so the auto-inject loop doesn't flood. The hook DLL probes both names on attach so the IPC pairs up symmetrically. Release builds keep their elevated `Global\` cross-session reach; debug builds work against same-session targets without admin. Also adds a dedicated `time-mocker-test-target` crate — a small console binary that prints all 5 hooked time APIs every second with its own PID banner, so dev verification can inject into a controlled harness instead of arbitrary running processes.
This commit is contained in:
Generated
+7
@@ -2447,6 +2447,13 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-mocker-test-target"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-mocker-ui"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/time-mocker-core",
|
||||
"crates/time-mocker-hook",
|
||||
"crates/time-mocker-test-target",
|
||||
"crates/time-mocker-ui",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ A Windows tool that injects fake time into running processes by hooking Win32 ti
|
||||
```
|
||||
time-mocker-rs/
|
||||
├── crates/
|
||||
│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions
|
||||
│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour
|
||||
│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID
|
||||
│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions
|
||||
│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour
|
||||
│ ├── time-mocker-test-target/ — console binary that prints all 5 hooked APIs in a loop (dedicated dev target)
|
||||
│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID
|
||||
```
|
||||
|
||||
## Hooked APIs
|
||||
@@ -29,11 +30,18 @@ time-mocker-rs/
|
||||
Named Memory-Mapped File per injected process:
|
||||
|
||||
```
|
||||
Name: TimeMocker_<PID>
|
||||
Name: Global\TimeMocker_<PID> (preferred — requires elevation)
|
||||
Local\TimeMocker_<PID> (fallback — same-session, unelevated dev)
|
||||
Size: 8 bytes
|
||||
[0..7] DeltaTicks (i64 — 100-ns units, added to the real FILETIME)
|
||||
```
|
||||
|
||||
The UI tries `Global\` first and falls back to `Local\` on `ERROR_ACCESS_DENIED`
|
||||
(i.e. when the controller is not elevated and the token lacks
|
||||
`SeCreateGlobalPrivilege`). The hook DLL probes both names. Release builds
|
||||
embed a UAC manifest, so they always get `Global\`; debug builds run
|
||||
unelevated and use `Local\` for same-session targets.
|
||||
|
||||
The hook reads the delta on every time API call and returns `real_filetime + delta`. The controller writes the delta whenever the user picks a new fake time.
|
||||
|
||||
> **Tick epoch difference vs the C# version:** The C# version stores a delta against `DateTime.UtcNow.Ticks` (epoch 0001-01-01 UTC). The Rust version stores a delta in raw FILETIME units (epoch 1601-01-01 UTC). The two IPC contracts are not interoperable — the Rust UI and Rust hook DLL only talk to each other.
|
||||
@@ -54,7 +62,26 @@ cargo build --release
|
||||
|
||||
- Windows 10/11 x64
|
||||
- Rust nightly (pinned via `rust-toolchain.toml`)
|
||||
- Must run as Administrator (UAC manifest embedded)
|
||||
- Release build: must run as Administrator (UAC manifest embedded; needed for `Global\` MMF and cross-session injection)
|
||||
- Debug build (`cargo run`): runs unelevated; falls back to `Local\` namespace — same-session targets only
|
||||
|
||||
## Dev workflow: test target
|
||||
|
||||
Inject into a dedicated harness instead of arbitrary running processes:
|
||||
|
||||
```powershell
|
||||
# Terminal 1 — start the target, note the PID it prints
|
||||
cargo run -p time-mocker-test-target
|
||||
|
||||
# Terminal 2 — start the UI; type that PID into "Inject by PID"
|
||||
cargo build --workspace ; cargo run -p time-mocker-ui
|
||||
```
|
||||
|
||||
The target prints all 5 hooked APIs (`GetSystemTime`, `GetLocalTime`,
|
||||
`GetSystemTimeAsFileTime`, `GetSystemTimePreciseAsFileTime`,
|
||||
`NtQuerySystemTime`) every second. When the hook is loaded and you set a
|
||||
fake time in the UI, all five rows shift by the same delta — that's your
|
||||
proof the hook is live.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Shared types and named MMF helper for time-mocker.
|
||||
//!
|
||||
//! IPC contract: an 8-byte memory-mapped file named `Global\TimeMocker_<pid>`
|
||||
//! holds an i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to
|
||||
//! the real system FILETIME by the injected hook.
|
||||
//! (or `Local\TimeMocker_<pid>` as a session-scoped fallback) holds an i64
|
||||
//! `DeltaTicks` — the offset (in 100-ns FILETIME units) added to the real
|
||||
//! system FILETIME by the injected hook.
|
||||
//!
|
||||
//! The `Global\` namespace lets the admin controller in session 1 reach
|
||||
//! processes in session 0 (services) and other sessions. Single-user same-
|
||||
//! session injection works too — `Global\` is a superset of `Local\`.
|
||||
//! processes in session 0 (services) and other sessions, but creating an
|
||||
//! object there requires `SeCreateGlobalPrivilege` — granted only to elevated
|
||||
//! tokens. Debug / non-elevated controllers fall back to `Local\` so dev
|
||||
//! workflows can still hook same-session targets.
|
||||
//!
|
||||
//! Both endpoints (writer = UI, reader = hook DLL) try `Global\` first and
|
||||
//! then `Local\`, so a controller's elevation state determines the namespace
|
||||
//! used and the hook just probes both.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
@@ -17,9 +24,18 @@ pub mod types;
|
||||
pub use mmf::{CreateOutcome, SharedDeltaReader, SharedDeltaWriter};
|
||||
pub use types::MockTimeInfo;
|
||||
|
||||
pub const MMF_PREFIX: &str = "Global\\TimeMocker_";
|
||||
pub const MMF_PREFIX_GLOBAL: &str = "Global\\TimeMocker_";
|
||||
pub const MMF_PREFIX_LOCAL: &str = "Local\\TimeMocker_";
|
||||
|
||||
/// Back-compat alias: the primary (elevated) namespace.
|
||||
pub const MMF_PREFIX: &str = MMF_PREFIX_GLOBAL;
|
||||
|
||||
#[inline]
|
||||
pub fn mmf_name_for_pid(pid: u32) -> String {
|
||||
format!("{MMF_PREFIX}{pid}")
|
||||
format!("{MMF_PREFIX_GLOBAL}{pid}")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn local_mmf_name_for_pid(pid: u32) -> String {
|
||||
format!("{MMF_PREFIX_LOCAL}{pid}")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::ffi::{c_void, OsStr};
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
|
||||
use time_mocker_core::{mmf_name_for_pid, SharedDeltaReader};
|
||||
use time_mocker_core::{local_mmf_name_for_pid, mmf_name_for_pid, SharedDeltaReader};
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HMODULE, TRUE};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW;
|
||||
use windows_sys::Win32::System::LibraryLoader::DisableThreadLibraryCalls;
|
||||
@@ -57,16 +57,23 @@ unsafe extern "system" fn bootstrap_thread(_param: *mut c_void) -> u32 {
|
||||
|
||||
fn bootstrap() {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let name = mmf_name_for_pid(pid);
|
||||
let global = mmf_name_for_pid(pid);
|
||||
let local = local_mmf_name_for_pid(pid);
|
||||
|
||||
let shared = match SharedDeltaReader::open(&name) {
|
||||
// Probe Global\ first (matches the elevated controller's preferred namespace);
|
||||
// fall back to Local\ for the unelevated dev/debug controller path. Either
|
||||
// name owns an identical 8-byte payload — whichever exists wins.
|
||||
let shared = match SharedDeltaReader::open(&global) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Controller didn't set up the MMF (cross-session DACL, stale inject,
|
||||
// or never-injected debug case). Surface via OutputDebugStringW for DbgView.
|
||||
dbg_log(&format!("time-mocker: open MMF '{name}' failed: {e}"));
|
||||
return;
|
||||
}
|
||||
Err(e_global) => match SharedDeltaReader::open(&local) {
|
||||
Ok(s) => s,
|
||||
Err(e_local) => {
|
||||
dbg_log(&format!(
|
||||
"time-mocker: open MMF '{global}' ({e_global}) and '{local}' ({e_local}) both failed"
|
||||
));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Best-effort install. Detours stay armed for the lifetime of the host
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "time-mocker-test-target"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Dedicated injection target — prints all 5 hooked time APIs in a loop so you can verify the hook DLL without touching arbitrary running processes"
|
||||
|
||||
[[bin]]
|
||||
name = "time_mocker_test_target"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Time",
|
||||
] }
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Dedicated injection target for time-mocker.
|
||||
//!
|
||||
//! Prints its own PID at startup, then loops printing the five hooked time
|
||||
//! APIs side-by-side every second. Lets you verify the hook DLL is working
|
||||
//! against an isolated process you control, instead of injecting into
|
||||
//! arbitrary running programs.
|
||||
//!
|
||||
//! Usage:
|
||||
//! 1. Build the workspace: `cargo build --workspace`
|
||||
//! 2. Run this binary in one terminal — it prints its PID on line 1.
|
||||
//! 3. In the TimeMocker UI, type that PID into Inject by PID and set a fake time.
|
||||
//! 4. Watch the times printed by this binary shift by the delta you set.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
use windows_sys::Win32::System::SystemInformation::{GetLocalTime, GetSystemTime};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||
|
||||
type FnGetSystemTimeAsFileTime = unsafe extern "system" fn(*mut FILETIME);
|
||||
type FnNtQuerySystemTime = unsafe extern "system" fn(*mut i64) -> i32;
|
||||
|
||||
fn main() {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
|
||||
println!("================================================================");
|
||||
println!(" time-mocker test target");
|
||||
println!(" PID = {pid}");
|
||||
println!(" copy this PID into the TimeMocker UI to inject the hook here.");
|
||||
println!(" press Ctrl+C to stop.");
|
||||
println!("================================================================");
|
||||
println!();
|
||||
|
||||
// Resolve the two APIs that windows-sys' default features don't always
|
||||
// surface (GetSystemTimeAsFileTime + NtQuerySystemTime). Matching the
|
||||
// hook DLL's resolution strategy (`hooks.rs::resolve`) keeps the call
|
||||
// sites symmetric — what the hook hooks, this binary calls.
|
||||
let get_system_time_as_file_time = unsafe {
|
||||
resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimeAsFileTime")
|
||||
};
|
||||
let get_system_time_precise_as_file_time = unsafe {
|
||||
resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
|
||||
};
|
||||
let nt_query_system_time =
|
||||
unsafe { resolve::<FnNtQuerySystemTime>("ntdll.dll", "NtQuerySystemTime") };
|
||||
|
||||
if get_system_time_as_file_time.is_none() {
|
||||
eprintln!("warn: GetSystemTimeAsFileTime not found");
|
||||
}
|
||||
if get_system_time_precise_as_file_time.is_none() {
|
||||
eprintln!("warn: GetSystemTimePreciseAsFileTime not found (pre-Win8?)");
|
||||
}
|
||||
if nt_query_system_time.is_none() {
|
||||
eprintln!("warn: NtQuerySystemTime not found");
|
||||
}
|
||||
|
||||
let mut tick: u64 = 0;
|
||||
loop {
|
||||
tick += 1;
|
||||
println!("--- sample #{tick} ---");
|
||||
|
||||
// GetSystemTime — UTC SYSTEMTIME (kernel32).
|
||||
let mut st_utc: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { GetSystemTime(&mut st_utc) };
|
||||
println!(" GetSystemTime UTC {}", fmt_systemtime(&st_utc));
|
||||
|
||||
// GetLocalTime — local SYSTEMTIME (kernel32).
|
||||
let mut st_local: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { GetLocalTime(&mut st_local) };
|
||||
println!(" GetLocalTime local {}", fmt_systemtime(&st_local));
|
||||
|
||||
// GetSystemTimeAsFileTime — FILETIME 100-ns ticks since 1601-01-01 UTC.
|
||||
if let Some(f) = get_system_time_as_file_time {
|
||||
let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { f(&mut ft) };
|
||||
println!(
|
||||
" GetSystemTimeAsFileTime {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
}
|
||||
|
||||
// GetSystemTimePreciseAsFileTime — same units, sub-µs precision.
|
||||
if let Some(f) = get_system_time_precise_as_file_time {
|
||||
let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
unsafe { f(&mut ft) };
|
||||
println!(
|
||||
" GetSystemTimePreciseAsFT {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
}
|
||||
|
||||
// NtQuerySystemTime — raw i64 (also 100-ns ticks since 1601-01-01 UTC).
|
||||
if let Some(f) = nt_query_system_time {
|
||||
let mut ticks: i64 = 0;
|
||||
let status = unsafe { f(&mut ticks) };
|
||||
if status == 0 {
|
||||
let ft = FILETIME {
|
||||
dwLowDateTime: (ticks as u64 & 0xFFFF_FFFF) as u32,
|
||||
dwHighDateTime: ((ticks as u64) >> 32) as u32,
|
||||
};
|
||||
println!(
|
||||
" NtQuerySystemTime {}",
|
||||
fmt_filetime_as_systemtime(&ft)
|
||||
);
|
||||
} else {
|
||||
println!(" NtQuerySystemTime NTSTATUS={status:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn resolve<F: Copy>(module: &str, proc_name: &str) -> Option<F> {
|
||||
let module_c = CString::new(module).ok()?;
|
||||
let proc_c = CString::new(proc_name).ok()?;
|
||||
let h = GetModuleHandleA(module_c.as_ptr() as *const u8);
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
let addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?;
|
||||
Some(std::mem::transmute_copy::<_, F>(&addr))
|
||||
}
|
||||
|
||||
fn fmt_systemtime(st: &SYSTEMTIME) -> String {
|
||||
format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}",
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
fn fmt_filetime_as_systemtime(ft: &FILETIME) -> String {
|
||||
// Render the FILETIME via FileTimeToSystemTime so the columns line up
|
||||
// with the SYSTEMTIME-returning APIs above.
|
||||
use windows_sys::Win32::System::Time::FileTimeToSystemTime;
|
||||
let mut st: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() };
|
||||
let ok = unsafe { FileTimeToSystemTime(ft, &mut st) };
|
||||
if ok == 0 {
|
||||
let raw: u64 = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64;
|
||||
format!("(raw {raw}; FileTimeToSystemTime failed)")
|
||||
} else {
|
||||
format!("UTC {}", fmt_systemtime(&st))
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,17 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use dll_syringe::process::OwnedProcess;
|
||||
use dll_syringe::Syringe;
|
||||
use time_mocker_core::{mmf_name_for_pid, CreateOutcome, SharedDeltaWriter};
|
||||
use time_mocker_core::{
|
||||
local_mmf_name_for_pid, mmf_name_for_pid, CreateOutcome, SharedDeltaWriter,
|
||||
};
|
||||
|
||||
use crate::win32_process_info::{is_native_x64, query_full_image_name, IMAGE_FILE_MACHINE_AMD64};
|
||||
|
||||
/// Windows `ERROR_ACCESS_DENIED`. `CreateFileMappingW` on a `Global\` name returns
|
||||
/// this when the caller's token lacks `SeCreateGlobalPrivilege` (i.e. the UI is
|
||||
/// not running elevated). We use it as the trigger to fall back to `Local\`.
|
||||
const ERROR_ACCESS_DENIED: i32 = 5;
|
||||
|
||||
/// Critical Windows processes that must never be injected — they would
|
||||
/// destabilize the OS, fail with access denied, or trigger AV alerts.
|
||||
const SYSTEM_PROCESS_EXCLUDE: &[&str] = &[
|
||||
@@ -59,6 +66,9 @@ pub struct InjectionManager {
|
||||
injected: HashMap<u32, InjectedProcess>,
|
||||
hook_dll_path: PathBuf,
|
||||
pub log: VecDeque<String>,
|
||||
/// One-shot guard so the "running unelevated → Local\ fallback" warning is
|
||||
/// logged once per session, not on every auto-inject scan tick.
|
||||
local_fallback_warned: bool,
|
||||
}
|
||||
|
||||
impl InjectionManager {
|
||||
@@ -85,6 +95,7 @@ impl InjectionManager {
|
||||
injected: HashMap::new(),
|
||||
hook_dll_path,
|
||||
log: VecDeque::new(),
|
||||
local_fallback_warned: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,6 +107,36 @@ impl InjectionManager {
|
||||
self.injected.contains_key(&pid)
|
||||
}
|
||||
|
||||
/// Try `Global\TimeMocker_<pid>` first, then fall back to
|
||||
/// `Local\TimeMocker_<pid>` on ERROR_ACCESS_DENIED (the controller is not
|
||||
/// elevated). Cross-session reach is lost in the fallback path, but the
|
||||
/// hook DLL probes both namespaces so same-session targets still work.
|
||||
fn create_mmf_with_fallback(
|
||||
&mut self,
|
||||
pid: u32,
|
||||
) -> Result<(String, SharedDeltaWriter, CreateOutcome)> {
|
||||
let global = mmf_name_for_pid(pid);
|
||||
match SharedDeltaWriter::create(&global) {
|
||||
Ok((delta, outcome)) => Ok((global, delta, outcome)),
|
||||
Err(e) if e.raw_os_error() == Some(ERROR_ACCESS_DENIED) => {
|
||||
if !self.local_fallback_warned {
|
||||
self.local_fallback_warned = true;
|
||||
self.log_push(
|
||||
"warn: controller not elevated — falling back to Local\\ namespace; \
|
||||
cross-session targets will not be reachable. Run as Administrator \
|
||||
(release build) for full reach.".into(),
|
||||
);
|
||||
}
|
||||
let local = local_mmf_name_for_pid(pid);
|
||||
let (delta, outcome) = SharedDeltaWriter::create(&local).with_context(|| {
|
||||
format!("create MMF {local} (after {global} returned access denied)")
|
||||
})?;
|
||||
Ok((local, delta, outcome))
|
||||
}
|
||||
Err(e) => Err(anyhow::Error::from(e).context(format!("create MMF {global}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn iter(&self) -> impl Iterator<Item = &InjectedProcess> {
|
||||
self.injected.values()
|
||||
@@ -152,9 +193,7 @@ impl InjectionManager {
|
||||
));
|
||||
}
|
||||
|
||||
let mmf_name = mmf_name_for_pid(pid);
|
||||
let (delta, outcome) = SharedDeltaWriter::create(&mmf_name)
|
||||
.with_context(|| format!("create MMF {mmf_name}"))?;
|
||||
let (mmf_name, delta, outcome) = self.create_mmf_with_fallback(pid)?;
|
||||
if outcome == CreateOutcome::Existed {
|
||||
self.log_push(format!(
|
||||
"warn: MMF {mmf_name} pre-existed (stale prior session?)"
|
||||
@@ -361,6 +400,7 @@ mod tests {
|
||||
injected: HashMap::new(),
|
||||
hook_dll_path: std::path::PathBuf::from("dummy.dll"),
|
||||
log: VecDeque::new(),
|
||||
local_fallback_warned: false,
|
||||
};
|
||||
|
||||
// Push LOG_CAP + 100 entries and verify only LOG_CAP remain
|
||||
|
||||
Reference in New Issue
Block a user