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..f4d981b 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); } - - 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;