From e8f8482f6a5d6bb0b73614316a51557d4ceee703 Mon Sep 17 00:00:00 2001 From: Rushaway Date: Tue, 6 Oct 2026 21:21:24 +0200 Subject: [PATCH 1/2] perf(core): spread spray folder scans over timer ticks OnMapEnd walked the whole download folder and moved the first 52 sprays in one call (89 ms), and each TimerNext tick moved 52 more through Valve filesystem calls (57 ms). - OnMapEnd now only starts the scan and does no file work - One SprayScan job walks, checks or moves, then removes emptied folders, with at most SCAN_BUDGET (2 ms) of work per 0.1 s tick - Scan file calls use the plain filesystem instead of Valve FS - Read the queued files by index instead of Erase(0) - Create the backup folder that sprays are actually moved into - sm_spray_test falls back to the server console if the admin leaves Fixes #36 Co-Authored-By: Claude Opus 5.5 --- .github/copilot-instructions.md | 16 +- addons/sourcemod/scripting/FixSprayExploit.sp | 440 ++++++++---------- 2 files changed, 206 insertions(+), 250 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 072c880..0d2a2f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -93,7 +93,7 @@ spcomp -i addons/sourcemod/scripting/include -o addons/sourcemod/plugins/FixSpra ### Best Practices Applied ✅ **Memory Management**: Uses `delete` for StringMaps/ArrayLists instead of `.Clear()` -✅ **Async Operations**: File operations use timers to prevent timeouts +✅ **Async Operations**: Folder scans (`SprayScan`) run on a timer with a per-tick time budget (`SCAN_BUDGET`) and never do file work inside `OnMapEnd` ✅ **StringMaps over Arrays**: Efficient data storage ✅ **Error Handling**: Comprehensive validation and logging ✅ **Performance**: Caching validated files, batched processing @@ -109,7 +109,7 @@ spcomp -i addons/sourcemod/scripting/include -o addons/sourcemod/plugins/FixSpra ### Adding New Exploit Detection 1. Update `g_iVal[]` array with new byte patterns 2. Modify `ValFile()` function for new validation logic -3. Add corresponding test cases to `RecursiveSearchDirs()` +3. Add corresponding test cases to `ScanTestFile()` 4. Update `g_hCvarPunish` handling if needed ### Integrating New Banning Systems @@ -119,7 +119,8 @@ spcomp -i addons/sourcemod/scripting/include -o addons/sourcemod/plugins/FixSpra 4. Mark natives as optional in `AskPluginLoad2()` ### Performance Optimization -- Monitor `MAX_READ` constant (current: 50 files per batch) +- Tune `SCAN_BUDGET` (current: 2 ms of file work per 0.1 s timer tick) instead of processing a fixed number of files per batch +- Scan file operations use the plain filesystem (`use_valve_fs` false): Valve FS lookups search every game path and cost far more per call - Use `TIMEOUT_LOG` to prevent log spam (current: 10.0 seconds) - Cache results in StringMaps to avoid repeated validation - Use `RequestFrame()` for async operations @@ -205,7 +206,7 @@ if( condition_check ) { 1. **Build Failures**: Check the GitHub Actions CI logs for compiler or dependency errors 2. **File Not Found**: Verify `spray_exploit_fixer_path` cvar is correct 3. **Memory Leaks**: Ensure StringMaps are `delete`d, not `.Clear()`ed -4. **Timeouts**: Increase batch size or timer delays for large file sets +4. **Server hitches**: Lower `SCAN_BUDGET`; large folders then take more ticks but each tick stays short ### Debug Logging - Set `spray_exploit_fixer_log 1` for all activity @@ -213,9 +214,8 @@ if( condition_check ) { - Check `sourcemod/logs/spray_downloads.log` for detailed logs ### Performance Monitoring -- Monitor `g_iTotal` counter in batch processing -- Check `g_fTime` for operation duration in `CmdSprays` -- Watch for script execution timeouts during file operations +- `sm_spray_test` reports the total scan duration when it finishes +- Each `TimerScan` call should stay close to `SCAN_BUDGET`; a single slow file operation can still exceed it This plugin demonstrates advanced SourcePawn techniques for file validation, async processing, and multi-platform compatibility. Focus on maintaining the security-first approach while ensuring server performance. @@ -227,7 +227,7 @@ This plugin demonstrates advanced SourcePawn techniques for file validation, asy - `.github/workflows/ci.yml` - CI/CD pipeline ### Important Constants -- `MAX_READ = 50` - Files processed per batch +- `SCAN_BUDGET = 0.002` - Seconds of file work per scan timer tick - `TIMEOUT_LOG = 10.0` - Seconds between duplicate log entries - `PATH_BACKUP = "backup_sprays"` - Invalid spray storage folder diff --git a/addons/sourcemod/scripting/FixSprayExploit.sp b/addons/sourcemod/scripting/FixSprayExploit.sp index cef8be4..a642658 100644 --- a/addons/sourcemod/scripting/FixSprayExploit.sp +++ b/addons/sourcemod/scripting/FixSprayExploit.sp @@ -197,7 +197,7 @@ #include -#define MAX_READ 50 +#define SCAN_BUDGET 0.002 // srcdslab: seconds of file work allowed per scan timer tick #define TIMEOUT_LOG 10.0 #define PATH_BACKUP "backup_sprays" @@ -220,8 +220,28 @@ #define MAX_SIZE 64 int g_iVal[MAX_SIZE] = {86,84,70,0,7,0,0,0,42,0,0,0,42,0,0,0,42,42,42,42,42,42,42,42,42,42,42,0,0,0,0,0,0,0,0,0}; bool g_bIsVTFExploit[MAXPLAYERS+1] = {false, ...}; // srcdslab: stop echoing blocked decals back to flagged clients + +// srcdslab: state of an incremental downloads folder scan, spread over timer ticks +enum struct SprayScan +{ + bool active; + bool move; + int userid; + int count; + int counts; + int index; + float start; + char root[PLATFORM_MAX_PATH]; + char dir[PLATFORM_MAX_PATH]; + DirectoryListing listing; + ArrayList dirs; + ArrayList visited; + ArrayList files; +} + +SprayScan g_ScanMove; +SprayScan g_ScanTest; char g_sFilename[PLATFORM_MAX_PATH]; -char g_sMoveFiles[PLATFORM_MAX_PATH]; char g_sDownloads[PLATFORM_MAX_PATH]; char g_sPath1[MAXPLAYERS+1][PLATFORM_MAX_PATH]; char g_sPath2[MAXPLAYERS+1][PLATFORM_MAX_PATH]; @@ -233,10 +253,7 @@ EngineVersion g_iEngine; StringMap g_smChecked; StringMap g_smReceive; StringMap g_smWaiting; -int g_iTotal; -float g_fTime; bool g_bLate; -bool g_bProc; bool g_bDecal; bool g_bSourceBans; bool g_bMaterialAdmin; @@ -333,12 +350,6 @@ public void OnPluginStart() AddTempEntHook("Player Decal", PlayerDecal); - char sPath[PLATFORM_MAX_PATH]; - strcopy(sPath, sizeof(sPath), g_sDownloads); - ReplaceString(sPath, sizeof(sPath), "/cc", ""); - StrCat(sPath, sizeof(sPath), PATH_BACKUP); - CreateDirectory(sPath, 511, true); - if( !g_bLate ) MoveSprays(); } @@ -496,7 +507,7 @@ void ConVarChanged_Cvars(Handle convar, const char[] oldValue, const char[] newV Action CmdSprays(int client, int args) { - if( g_bProc ) + if( g_ScanTest.active ) { ReplyToCommand(client, "[Sprays] Already processing."); return Plugin_Handled; @@ -504,293 +515,238 @@ Action CmdSprays(int client, int args) ReplyToCommand(client, "[Sprays] checking files, please wait..."); - g_iTotal = 0; - g_bProc = true; - g_fTime = GetEngineTime(); - - ArrayList aList = new ArrayList(ByteCountToCells(PLATFORM_MAX_PATH)); + ScanStart(g_ScanTest, false, client); - int pos = StrContains(g_sDownloads, "/"); - if( pos != -1 ) g_sDownloads[pos] = 0; + return Plugin_Handled; +} - RecursiveFiles(aList, false, g_sDownloads); +void MoveSprays() +{ + // If the previous move has not finished yet, let it run: anything it misses is moved at the next map end + if( !g_ScanMove.active ) + ScanStart(g_ScanMove, true, 0); +} - if( pos != -1 ) g_sDownloads[pos] = '/'; +// srcdslab: no file work happens here, it is all done by TimerScan within SCAN_BUDGET per tick +void ScanStart(SprayScan scan, bool move, int client) +{ + scan.active = true; + scan.move = move; + scan.userid = client ? GetClientUserId(client) : 0; + scan.count = 0; + scan.counts = 0; + scan.index = 0; + scan.start = GetEngineTime(); + + strcopy(scan.root, sizeof(scan.root), g_sDownloads); + int pos = StrContains(scan.root, "/"); + if( pos != -1 ) scan.root[pos] = 0; + + scan.listing = null; + scan.dirs = new ArrayList(ByteCountToCells(PLATFORM_MAX_PATH)); + scan.visited = new ArrayList(ByteCountToCells(PLATFORM_MAX_PATH)); + scan.files = new ArrayList(ByteCountToCells(PLATFORM_MAX_PATH)); + scan.dirs.PushString(scan.root); + + CreateTimer(0.1, TimerScan, move, TIMER_REPEAT); +} - int count, counts; - RecursiveSearchDirs(client, aList, count, counts, false); +Action TimerScan(Handle timer, bool move) +{ + bool running; + if( move ) + running = ScanRun(g_ScanMove); + else + running = ScanRun(g_ScanTest); - return Plugin_Handled; + return running ? Plugin_Continue : Plugin_Stop; } -void RecursiveFiles(ArrayList aList, bool move, const char sDir[PLATFORM_MAX_PATH]) +// Walks the directories, then checks or moves the queued sprays, then removes emptied directories. +// Returns false once the scan has finished. +bool ScanRun(SprayScan scan) { - FileType type; - DirectoryListing hDir; - File hFile; - int iRead[4]; - int moving; + static char sPath[PLATFORM_MAX_PATH]; + float limit = GetEngineTime() + SCAN_BUDGET; - if( DirExists(sDir) ) + while( GetEngineTime() < limit ) { - hDir = OpenDirectory(sDir, true); - - if( hDir ) + if( scan.listing ) + { + ScanEntry(scan); + } + else if( scan.dirs.Length ) { - char sPath[PLATFORM_MAX_PATH]; + int last = scan.dirs.Length - 1; + scan.dirs.GetString(last, scan.dir, sizeof(scan.dir)); + scan.dirs.Erase(last); - while( ReadDirEntry(hDir, sPath, sizeof(sPath), type) ) - { - if( strcmp(sPath, ".") && strcmp(sPath, "..") ) - { - moving = 0; + scan.listing = OpenDirectory(scan.dir); - switch( type ) - { - case FileType_Directory: - { - if( !move || strcmp(sPath, PATH_BACKUP) ) - { - Format(sPath, sizeof(sPath), "%s/%s", sDir, sPath); - RecursiveFiles(aList, move, sPath); - } - } - case FileType_File: - { - int len = strlen(sPath); - if( len > 4 ) - { - if( strcmp(sPath[len - 4], ".dat") == 0 ) - moving = 1; - else if( move && strcmp(sPath[len - 5], ".ztmp") == 0) - moving = 2; - - if( moving ) - { - if( moving == 2 ) - { - Format(sPath, sizeof(sPath), "%s/%s", sDir, sPath); - ReplaceString(sPath, sizeof(sPath), ".ztmp", ""); - - if( FileExists(sPath, true) == false ) - { - moving = 0; - } - else - { - StrCat(sPath, sizeof(sPath), ".ztmp"); - } - } - else - { - Format(sPath, sizeof(sPath), "%s/%s", sDir, sPath); - } - - if( moving ) - { - hFile = OpenFile(sPath, "rb", false); - if( hFile ) - { - hFile.Read(iRead, sizeof(iRead), 1); - delete hFile; - if( - (iRead[0] == 86 && iRead[1] == 84 && iRead[2] == 70 && iRead[3] == 0) || - (moving == 2 && iRead[0] == 76 && iRead[1] == 90 && iRead[2] == 83 && iRead[3] == 83) - ) - { - aList.PushString(sPath); - } - } - } - } - } - } - } - } + if( scan.move && scan.listing && strcmp(scan.dir, scan.root) == 0 ) + { + FormatEx(sPath, sizeof(sPath), "%s/%s", scan.root, PATH_BACKUP); + if( !DirExists(sPath) ) CreateDirectory(sPath, 511, true); } - - delete hDir; } - } -} - -void MoveSprays() -{ - int count, counts; - - ArrayList aList = new ArrayList(ByteCountToCells(PLATFORM_MAX_PATH)); + else if( scan.index < scan.files.Length ) + { + scan.files.GetString(scan.index++, sPath, sizeof(sPath)); - strcopy(g_sMoveFiles, sizeof(g_sMoveFiles), g_sDownloads); + if( scan.move ) + ScanMoveFile(scan, sPath); + else + ScanTestFile(scan, sPath); + } + else if( scan.visited.Length ) + { + // Children were queued after their parents, so removing in reverse empties a tree bottom up. + // RemoveDir fails on directories that still hold files, so only empty ones are deleted. + int last = scan.visited.Length - 1; + scan.visited.GetString(last, sPath, sizeof(sPath)); + scan.visited.Erase(last); - int pos = StrContains(g_sMoveFiles, "/"); - if( pos != -1 ) g_sMoveFiles[pos] = 0; + RemoveDir(sPath); + } + else + { + if( !scan.move ) + ReplyToCommand(ScanClient(scan), "[Sprays] downloads checked. Found %d of %d invalid. Took %0.2f seconds.", scan.count, scan.counts, GetEngineTime() - scan.start); + + delete scan.dirs; + delete scan.visited; + delete scan.files; + scan.active = false; + return false; + } + } - RecursiveFiles(aList, true, g_sMoveFiles); - RecursiveSearchDirs(0, aList, count, counts, true); + return true; } -void RecursiveSearchDirs(int client, ArrayList aList, int &count, int &counts, bool move = false) +// Reads one entry of the open directory, queueing sub-directories and sprays with a valid header +void ScanEntry(SprayScan scan) { - static char sNew[PLATFORM_MAX_PATH]; static char sPath[PLATFORM_MAX_PATH]; + FileType type; - File hFile; - int iRead[sizeof(g_iVal)]; - int len, pos, i; - - while( aList.Length > 0 ) + if( !ReadDirEntry(scan.listing, sPath, sizeof(sPath), type) ) { - aList.GetString(0, sPath, sizeof(sPath)); - aList.Erase(0); + delete scan.listing; + return; + } - len = strlen(sPath); - if( (len > 4 && strcmp(sPath[len - 4], ".dat") == 0) || (move && len > 4 && strcmp(sPath[len - 5], ".ztmp") == 0) ) + if( type == FileType_Directory ) + { + if( strcmp(sPath, ".") && strcmp(sPath, "..") && (!scan.move || strcmp(sPath, PATH_BACKUP)) ) { - if( move ) - { - pos = FindCharInString(sPath, '/', true); - if( pos != -1 ) sPath[pos] = 0; - - FormatEx(sNew, sizeof(sNew), "%s/%s/%s", g_sMoveFiles, PATH_BACKUP, sPath[pos + 1]); - if( FileExists(sNew, true) ) DeleteFile(sNew, true); - - if( pos != -1 ) sPath[pos] = '/'; + Format(sPath, sizeof(sPath), "%s/%s", scan.dir, sPath); + scan.dirs.PushString(sPath); + if( scan.move ) scan.visited.PushString(sPath); + } + return; + } - RenameFile(sNew, sPath, true); - } - else - { - counts++; + if( type != FileType_File ) return; - hFile = OpenFile(sPath, "rb"); - if( hFile ) - { - hFile.Read(iRead, sizeof(iRead), 1); + int len = strlen(sPath); + if( len <= 4 ) return; - delete hFile; + int moving; + if( strcmp(sPath[len - 4], ".dat") == 0 ) + moving = 1; + else if( scan.move && strcmp(sPath[len - 5], ".ztmp") == 0 ) + moving = 2; + else + return; - i = ValFile(iRead); - if( i != -1 ) - { - count++; + Format(sPath, sizeof(sPath), "%s/%s", scan.dir, sPath); - PrintToConsole(client, "Invalid file: %s: %02d (%02X <> %02X)", sPath, i, iRead[i], g_iVal[i]); - } + // Only move compressed temp files whose spray still exists + if( moving == 2 ) + { + len = strlen(sPath); + sPath[len - 5] = 0; + bool exists = FileExists(sPath); + sPath[len - 5] = '.'; - int calculatedSize = CalcSize(iRead); - if( calculatedSize != -1 ) - { - int fileSize = FileSize(sPath); - if( fileSize > 1000 && calculatedSize > 1000 ) - { - int diff = RoundToCeil(fileSize * 5 / 100.0); + if( !exists ) return; + } - if( calculatedSize > fileSize + diff || calculatedSize < fileSize - diff ) - { - PrintToConsole(client, "Invalid size: %s: (%d <> %d)", sPath, calculatedSize, fileSize); - } - } - } - } - } - } + int iRead[4]; + File hFile = OpenFile(sPath, "rb"); + if( !hFile ) return; - if( g_iTotal++ > MAX_READ ) - { - g_iTotal = 0; - - DataPack dPack; - CreateDataTimer(0.1, TimerNext, dPack); - dPack.WriteCell(client); - dPack.WriteCell(aList); - dPack.WriteCell(count); - dPack.WriteCell(counts); - dPack.WriteCell(move); - return; - } - } + hFile.Read(iRead, sizeof(iRead), 1); + delete hFile; - if( aList.Length == 0 ) + if( + (iRead[0] == 86 && iRead[1] == 84 && iRead[2] == 70 && iRead[3] == 0) || + (moving == 2 && iRead[0] == 76 && iRead[1] == 90 && iRead[2] == 83 && iRead[3] == 83) + ) { - if( !move ) - { - g_bProc = false; + scan.files.PushString(sPath); + } +} - ReplyToCommand(client, "[Sprays] downloads checked. Found %d of %d invalid. Took %0.2f seconds.", count, counts, GetEngineTime() - g_fTime); +void ScanMoveFile(SprayScan scan, const char[] sPath) +{ + static char sNew[PLATFORM_MAX_PATH]; - delete aList; - } - else - { - DeleteEmptyDirs(g_sMoveFiles); + int pos = FindCharInString(sPath, '/', true); + FormatEx(sNew, sizeof(sNew), "%s/%s/%s", scan.root, PATH_BACKUP, sPath[pos + 1]); - delete aList; - } - } + if( FileExists(sNew) ) DeleteFile(sNew); + RenameFile(sNew, sPath); } -Action TimerNext(Handle timer, DataPack dPack) +void ScanTestFile(SprayScan scan, const char[] sPath) { - ArrayList aList; - int client, count, counts; - bool move; + int iRead[sizeof(g_iVal)]; - dPack.Reset(); - client = dPack.ReadCell(); - aList = dPack.ReadCell(); - count = dPack.ReadCell(); - counts = dPack.ReadCell(); - move = dPack.ReadCell(); + scan.counts++; - RecursiveSearchDirs(client, aList, count, counts, move); + File hFile = OpenFile(sPath, "rb"); + if( !hFile ) return; - return Plugin_Continue; -} + hFile.Read(iRead, sizeof(iRead), 1); + delete hFile; -void DeleteEmptyDirs(const char sDir[PLATFORM_MAX_PATH]) -{ - FileType type; - DirectoryListing hDir; - bool del = true; + int client = ScanClient(scan); - if( DirExists(sDir) ) + int i = ValFile(iRead); + if( i != -1 ) { - hDir = OpenDirectory(sDir, true); + scan.count++; + + PrintToConsole(client, "Invalid file: %s: %02d (%02X <> %02X)", sPath, i, iRead[i], g_iVal[i]); + } - if( hDir ) + int calculatedSize = CalcSize(iRead); + if( calculatedSize != -1 ) + { + int fileSize = FileSize(sPath); + if( fileSize > 1000 && calculatedSize > 1000 ) { - char sPath[PLATFORM_MAX_PATH]; + int diff = RoundToCeil(fileSize * 5 / 100.0); - while( ReadDirEntry(hDir, sPath, sizeof(sPath), type) ) + if( calculatedSize > fileSize + diff || calculatedSize < fileSize - diff ) { - if( strcmp(sPath, ".") && strcmp(sPath, "..") ) - { - switch( type ) - { - case FileType_Directory: - { - Format(sPath, sizeof(sPath), "%s/%s", sDir, sPath); - DeleteEmptyDirs(sPath); - del = false; - } - case FileType_File: - { - del = false; - } - } - } + PrintToConsole(client, "Invalid size: %s: (%d <> %d)", sPath, calculatedSize, fileSize); } - - delete hDir; - } - - if( del ) - { - RemoveDir(sDir); } } } +// The admin who started the test, or the server console if they left or are changing map +int ScanClient(SprayScan scan) +{ + int client = GetClientOfUserId(scan.userid); + if( client && !IsClientInGame(client) ) client = 0; + + return client; +} + Action PlayerDecal(const char[] te_name, const int[] Players, int numClients, float delay) { if( g_bDecal ) return Plugin_Continue; From b546f254335b07505690a0d33f7477fe31527483 Mon Sep 17 00:00:00 2001 From: Rushaway Date: Wed, 7 Oct 2026 11:16:52 +0200 Subject: [PATCH 2/2] fix(core): create the backup folder with the plain filesystem The moves rename into the backup folder through plain paths under the game folder, so create it the same way instead of under Valve's write path. The scan root is already open at that point, so the non-recursive CreateDirectory is enough. Co-Authored-By: Claude Opus 5.5 --- addons/sourcemod/scripting/FixSprayExploit.sp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sourcemod/scripting/FixSprayExploit.sp b/addons/sourcemod/scripting/FixSprayExploit.sp index a642658..f4d981b 100644 --- a/addons/sourcemod/scripting/FixSprayExploit.sp +++ b/addons/sourcemod/scripting/FixSprayExploit.sp @@ -586,7 +586,7 @@ bool ScanRun(SprayScan scan) if( scan.move && scan.listing && strcmp(scan.dir, scan.root) == 0 ) { FormatEx(sPath, sizeof(sPath), "%s/%s", scan.root, PATH_BACKUP); - if( !DirExists(sPath) ) CreateDirectory(sPath, 511, true); + if( !DirExists(sPath) ) CreateDirectory(sPath, 511); } } else if( scan.index < scan.files.Length )