Files
2026-10-17 21:57:25 +08:00

514 lines
22 KiB
Plaintext

from stdint import *
import string
import viperlib
import memhub
import lib.VFs as VFs
import lib.VLog as VLog
import lib.VPe as VPe
import lib.VGuid as VGuid
import lib.VJson as VJson
import lib.VCache as VCache
import lib.VCheck as VCheck
import lib.VDirs as VDirs
import lib.VProc as VProc
import lib.VGuard as VGuard
import lib.VCli as VCli
import win32.win32file as wf
import win32.win32base as wb
# VScan - scan subcommand: directory tree walk, candidate filtering, progress, round driver.
# Contract: stdout = one full JSON array per round; stderr = logs (quiet by default, --verbose details).
define MAX_SCAN_DEPTH = 6 # max recursion depth
define PACE_DIR_UNITS = 16 # sleep every 16 directory entries
define PACE_FILE_UNITS = 64 # sleep every 64 files
define PACE_SLEEP_MS = 20 # Sleep(20) at each throttle point
define PROG_INTERVAL_MS = 1000 # min interval between progress lines
define PROG_STAMP_CAP = 64 # timestamp text buffer
define PROG_ELAPSED_CAP = 32 # "3.2s" elapsed text buffer
define REJECT_LOG_MAX = 20 # max per-line [reject] logs; rest summarized at round end
# WIN32_FIND_DATAA: cFileName starts at offset 44
define FD_ATTRIBUTES_OFF = 0
define FD_FILENAME_OFF = 44
define FIND_DATA_SIZE = 320 # sizeof(WIN32_FIND_DATAA): 318 aligned to 4 = 320
# winnt.h: reparse points (symlinks/mounts); skipped in recursion to avoid cycles
define FILE_ATTRIBUTE_REPARSE_POINT = 0x400
# injected by App/main.vp
_mbuddy: memhub.MemBuddy*
# scan static buffers (reused for process lifetime; single-threaded)
Scr_Ver: bytes = None
Scr_Arch: bytes = None
Scr_Id: bytes = None
Scr_Disp: bytes = None
Probe_Buf: bytes = None
Prog_Stamp: bytes = None
Prog_Elapsed: bytes = None
# throttle sleep switch: 1 = on (resident default), 0 = off (RunScan sets 0 for --once)
Pace_Enabled: int = 1
# Throttle switch for a regular round (computed by RunScan); Pace_Enabled is this round's value.
# RunScanRound restores it each round, then applies --first-no-pace to round 1 only.
Pace_Configured: int = 1
# Throttle duration and file granularity (mutable); defaults below unless --pace/--pace-units given.
# RunScan overrides them when given; then --once no longer disables throttling.
Pace_Sleep_Ms: int = PACE_SLEEP_MS # --pace <ms>; 0 = no Sleep at either throttle point
Pace_File_Units: int = PACE_FILE_UNITS # --pace-units <N>; file-side granularity (dir side stays 16)
# throttle counters
Pace_Dir_Count: int = 0
Pace_File_Count: int = 0
# progress state
Prog_Round: int = 0
Prog_Roots: int = 0
Prog_T0: ULONGLONG = 0
Prog_LastTick: ULONGLONG = 0
Prog_Dirs: int = 0
Prog_Files: int = 0
Prog_Cands: int = 0
Prog_Cur: str = None
Round_Added: int = 0
# [reject] failure-path counters (reset each round; single-threaded)
Reject_Shown: int = 0 # [reject] lines printed this round (cap REJECT_LOG_MAX)
Reject_Total: int = 0 # total candidates filtered by PE/.PRVERSN this round
# ---- Scan strategy helpers ----
# whether to skip this directory name (blacklist, case-insensitive)
def IsSkippedDir(Name: str) -> int:
if VFs.StrEqNoCase(Name, "WinSxS") != 0:
return 1
if VFs.StrEqNoCase(Name, "$Recycle.Bin") != 0:
return 1
if VFs.StrEqNoCase(Name, "System Volume Information") != 0:
return 1
if VFs.StrEqNoCase(Name, "node_modules") != 0:
return 1
if VFs.StrEqNoCase(Name, ".git") != 0:
return 1
return 0
# whether the file name ends with .exe (case-insensitive)
def HasExeSuffix(Name: str) -> int:
n: sizet = string.strlen(Name)
if n < 4:
return 0
a: int = int(Name[int(n) - 4])
b: int = int(Name[int(n) - 3])
d0: int = int(Name[int(n) - 2])
e0: int = int(Name[int(n) - 1])
if a >= 65 and a <= 90:
a += 32
if b >= 65 and b <= 90:
b += 32
if d0 >= 65 and d0 <= 90:
d0 += 32
if e0 >= 65 and e0 <= 90:
e0 += 32
if a == 46 and b == 101 and d0 == 120 and e0 == 101: # ".exe"
return 1
return 0
# Progress output (all via VLog -> stderr; quiet unless --verbose).
# Counter and throttle-baseline updates are unconditional; only the print step uses Verbose*.
# print one progress line (r/dir/file/cand/cur/elapsed) and refresh the time baseline
def ProgPrint():
global Prog_LastTick
Now: ULONGLONG = wb.GetTickCount64()
Prog_LastTick = Now
ElapsedMs: sizet = sizet(Now - Prog_T0)
Secs: sizet = ElapsedMs // 1000
Tenths: sizet = (ElapsedMs % 1000) // 100
viperlib.snprintf(Prog_Elapsed, sizet(PROG_ELAPSED_CAP), "%u.%us",
uint32(Secs), uint32(Tenths))
Cur: str = Prog_Cur
if Cur is None:
Cur = "?"
VLog.VerboseFmt4("[scan] r=%u dir=%u file=%u cand=%u",
sizet(Prog_Round), sizet(Prog_Dirs), sizet(Prog_Files), sizet(Prog_Cands))
VLog.VerboseFmtS2(" cur=%s elapsed=%s\n", Cur, str(Prog_Elapsed))
# print only if >= PROG_INTERVAL_MS since the last progress output
def ProgMaybePrint():
Now: ULONGLONG = wb.GetTickCount64()
if Now - Prog_LastTick >= ULONGLONG(PROG_INTERVAL_MS):
ProgPrint()
# round start: reset counters and print (round / root count / timestamp)
def ProgReset(RoundIdx: int, Roots: int):
global Prog_Round, Prog_Roots, Prog_T0, Prog_LastTick
global Prog_Dirs, Prog_Files, Prog_Cands, Prog_Cur
Prog_Round = RoundIdx
Prog_Roots = Roots
Prog_T0 = wb.GetTickCount64()
Prog_LastTick = Prog_T0
Prog_Dirs = 0
Prog_Files = 0
Prog_Cands = 0
Prog_Cur = None
VLog.TimeStampNow(Prog_Stamp, PROG_STAMP_CAP)
VLog.VerboseFmt2("==== scan round %u start (roots=%u", sizet(RoundIdx), sizet(Roots))
VLog.VerboseFmtS1(", t=%s) ====\n", str(Prog_Stamp))
def ProgRootEnter(Dir: str):
VLog.VerboseFmtS1(" [root] enter: %s\n", Dir)
def ProgRootLeave(Dir: str, ElapsedMs: sizet):
global Prog_Cur
VLog.VerboseFmtIS(" [root] leave (%u ms elapsed): %s\n", ElapsedMs, Dir)
Prog_Cur = None # path buffer is freed after this root; avoid a dangling pointer
def PaceDir():
global Pace_Dir_Count, Prog_Dirs
VGuard.PollIpc() # check-channel poll point (non-blocking; returns if idle)
Pace_Dir_Count += 1
Prog_Dirs += 1
if Pace_Dir_Count >= PACE_DIR_UNITS: # dir granularity is always 16 (--pace-units affects file side only)
Pace_Dir_Count = 0
# Pace_Sleep_Ms == 0 (--pace 0): do not call Sleep(0) -- it yields the time slice, not
# "disable throttle sleep". Counters and progress output still advance.
if Pace_Enabled != 0 and Pace_Sleep_Ms > 0:
wb.Sleep(ULONG(Pace_Sleep_Ms))
ProgPrint()
else:
ProgMaybePrint()
def PaceFile():
global Pace_File_Count, Prog_Files
VGuard.PollIpc() # check-channel poll point (non-blocking; returns if idle)
Pace_File_Count += 1
Prog_Files += 1
if Pace_File_Count >= Pace_File_Units: # file granularity set by --pace-units (default 64)
Pace_File_Count = 0
if Pace_Enabled != 0 and Pace_Sleep_Ms > 0: # same rationale as PaceDir (--pace 0 no Sleep(0))
wb.Sleep(ULONG(Pace_Sleep_Ms))
ProgMaybePrint()
# ---- Init ----
# allocate scan static buffers (reused for process lifetime)
def ScanInit() -> int:
global Scr_Ver, Scr_Arch, Scr_Id, Scr_Disp, Probe_Buf, Prog_Stamp, Prog_Elapsed
Scr_Ver = _mbuddy.alloc(VPe.VER_CAP)
Scr_Arch = _mbuddy.alloc(VPe.ARCH_CAP)
Scr_Id = _mbuddy.alloc(VGuid.GUID_STR_CAP)
Scr_Disp = _mbuddy.alloc(VCache.DISP_CAP)
Probe_Buf = _mbuddy.alloc(VProc.VERSION_PROBE_DIAG_CAP)
Prog_Stamp = _mbuddy.alloc(PROG_STAMP_CAP)
Prog_Elapsed = _mbuddy.alloc(PROG_ELAPSED_CAP)
if Scr_Ver is None or Scr_Arch is None or Scr_Id is None or Scr_Disp is None \
or Probe_Buf is None or Prog_Stamp is None or Prog_Elapsed is None:
VLog.LogStr("[VET] FATAL: scan buffer allocation failed\n")
return 0
if VJson.JsonInit() == 0:
VLog.LogStr("[VET] FATAL: scan buffer allocation failed\n")
return 0
# check channel buffers/reply pool: on failure degrade only (check replies
# CHANNEL_UNAVAILABLE, no revalidation removal); scan itself is unaffected.
if VCheck.CheckInit() == 0:
VLog.LogStr("Warning: check channel buffers unavailable; check requests only get the failure reason (CHANNEL_UNAVAILABLE), discovered entries are not revalidated each round\n")
return 1
# ---- Candidate handling ----
# --version prefix probe (5s timeout; timeout/startup failure => fail, not cached)
def ProbeVersion(Target: str) -> int:
rc: int = VProc.RunVersionProbe(Target, Probe_Buf, sizet(VProc.VERSION_PROBE_DIAG_CAP), 1)
if rc == 0:
if VProc.OutputHasViperPrefix(str(Probe_Buf)) != 0:
return 1
return 0
# Handle one .exe candidate: PE + .PRVERSN filter, then --version check; on hit add to cache.
# Return 1 = new hit, 0 = miss/existing/failure (TryReadPeInfo rc codes: see VPe.vp).
def HandleCandidate(ExeAnsi: str) -> int:
global Prog_Cands, Round_Added, Reject_Shown, Reject_Total
if VCache.CacheFind(ExeAnsi) >= 0:
return 0 # known: do not call --version again
rc: int = VPe.TryReadPeInfo(ExeAnsi, Scr_Ver, VPe.VER_CAP, Scr_Arch, VPe.ARCH_CAP)
if rc != 0:
# not PE or no .PRVERSN: log first REJECT_LOG_MAX lines, rest summarized at round end (--verbose)
Reject_Total += 1
if Reject_Shown < REJECT_LOG_MAX:
Reject_Shown += 1
VLog.VerboseFmtIS(" [reject] rc=%u %s\n", sizet(rc), ExeAnsi)
return 0
Prog_Cands += 1
# Call --version only for exes passing the PE + .PRVERSN filter (few).
# Poll the check channel before probing (a probe can take 5s; check outranks scanning).
VGuard.PollIpc()
if ProbeVersion(ExeAnsi) == 0:
VLog.VerboseFmtS1(" [skip] PE+PRVERSN hit but --version prefix mismatch: %s\n", ExeAnsi)
return 0
if VGuid.NewGuidStr(Scr_Id, VGuid.GUID_STR_CAP) == 0:
VLog.LogStr(" Warning: CoCreateGuid failed, skipping this entry\n")
return 0
viperlib.snprintf(Scr_Disp, sizet(VCache.DISP_CAP), "Viper %s", Scr_Ver)
if VCache.CacheAdd(ExeAnsi, str(Scr_Id), str(Scr_Ver), str(Scr_Arch),
"workspace", str(Scr_Disp)) == 0:
return 0
Round_Added += 1
VLog.VerboseFmtS2(" [hit] %s (version %s)\n", ExeAnsi, str(Scr_Ver))
return 1
# ---- Directory tree walk ----
# Recursively enumerate a directory for .exe files; Depth starts at 1, capped by MAX_SCAN_DEPTH.
# Two passes (dir\* then dir\*.exe) share one buffer; HasExeSuffix guards 8.3 short-name false hits.
def ScanTree(DirAnsi: str, Depth: int) -> int:
global Prog_Cur
Prog_Cur = DirAnsi # current directory for the progress line
Pat: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP)
Fd: bytes = _mbuddy.alloc(FIND_DATA_SIZE)
if Pat is None or Fd is None:
if Pat is not None:
_mbuddy.free(rawptr(Pat))
if Fd is not None:
_mbuddy.free(rawptr(Fd))
return 0
# ---- (1) dirs: enumerate subdirectories and recurse ----
if VFs.JoinPath(Pat, VFs.PATH_BUF_CAP, DirAnsi, "*") == 0:
_mbuddy.free(rawptr(Pat))
_mbuddy.free(rawptr(Fd))
return 0
hDir: wb.HANDLE = wf.FindFirstFileA(Pat, rawptr(Fd))
if hDir != wb.INVALID_HANDLE_VALUE:
while True:
PaceDir()
Attrs: uint32 = VPe.ReadU32(BYTEPTR(Fd), FD_ATTRIBUTES_OFF)
Name: str = str(Fd + FD_FILENAME_OFF)
if string.strcmp(Name, ".") != 0 and string.strcmp(Name, "..") != 0:
if (Attrs & wf.FILE_ATTRIBUTE_DIRECTORY) != 0:
# skip reparse points (symlinks/mounts) and blacklisted dirs to avoid cycles/noise
if (Attrs & FILE_ATTRIBUTE_REPARSE_POINT) == 0 and IsSkippedDir(Name) == 0:
if Depth < MAX_SCAN_DEPTH:
Child: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP)
if Child is not None:
if VFs.JoinPath(Child, VFs.PATH_BUF_CAP, DirAnsi, Name) != 0:
ScanTree(str(Child), Depth + 1)
Prog_Cur = DirAnsi # restore after recursion (Child is about to be freed)
_mbuddy.free(rawptr(Child))
if wf.FindNextFileA(hDir, rawptr(Fd)) == 0:
break
wf.FindClose(hDir)
# ---- (2) files: enumerate only *.exe (such dirs were recursed in (1), skip here) ----
if VFs.JoinPath(Pat, VFs.PATH_BUF_CAP, DirAnsi, "*.exe") != 0:
hExe: wb.HANDLE = wf.FindFirstFileA(Pat, rawptr(Fd))
if hExe != wb.INVALID_HANDLE_VALUE:
while True:
Attrs2: uint32 = VPe.ReadU32(BYTEPTR(Fd), FD_ATTRIBUTES_OFF)
Name2: str = str(Fd + FD_FILENAME_OFF)
if (Attrs2 & wf.FILE_ATTRIBUTE_DIRECTORY) == 0:
PaceFile()
if HasExeSuffix(Name2) != 0:
Full: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP)
if Full is not None:
if VFs.JoinPath(Full, VFs.PATH_BUF_CAP, DirAnsi, Name2) != 0:
HandleCandidate(str(Full))
_mbuddy.free(rawptr(Full))
if wf.FindNextFileA(hExe, rawptr(Fd)) == 0:
break
wf.FindClose(hExe)
_mbuddy.free(rawptr(Pat))
_mbuddy.free(rawptr(Fd))
return 0
# ---- One scan round ----
# Fully revalidate discovered entries and remove failures; returns the removed count.
# Same rule as VCheck.VerifyExecutable; runs before tree scan, polls check before each probe (<=5s).
def RevalidateDiscovered() -> int:
removed: int = 0
j: int = 0
while j < VCache.CacheCount():
Exe: str = VCache.CacheExe(j) # fetch first (RemoveAt frees this slot)
VGuard.PollIpc() # poll before probe (check first)
if VCheck.VerifyExecutable(Exe) == 0:
VLog.VerboseFmtS1(" [revalidate-remove] %s\n", Exe)
VCache.RemoveAt(j)
removed += 1
else:
j += 1
return removed
# Run one scan round: poll check -> reload cache.json -> revalidate -> walk dirs (polling check)
# -> validate existence -> poll check -> emit JSON to stdout -> write cache file.
def RunScanRound(CachePath: str, RoundIdx: int) -> int:
global Round_Added, Reject_Shown, Reject_Total
global Pace_Enabled
Round_Added = 0
Reject_Shown = 0
Reject_Total = 0
# This round's throttle switch: restore the regular value (RunScan computes it) then apply
# --first-no-pace for round 1 only, so the special case never leaks into later rounds.
Pace_Enabled = Pace_Configured
if VCli.FirstNoPace() != 0 and RoundIdx == 1:
Pace_Enabled = 0
T0: ULONGLONG = wb.GetTickCount64()
ProgReset(RoundIdx, VDirs.DirCount())
# Round start (order matters): reload cache.json, then handle queued check requests
# (after reload so new hits survive), then revalidate discovered entries before tree scan.
VCache.ReloadCache(CachePath)
VGuard.PollIpc()
Revalidated: int = RevalidateDiscovered()
Dirs: int = 0
for k in range(VDirs.DirCount()):
AnsiDir: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP)
if AnsiDir is not None:
VFs.Utf8ToAnsi(VDirs.DirAt(k), AnsiDir, VFs.PATH_BUF_CAP)
if VFs.PathIsDir(str(AnsiDir)) != 0:
Dirs += 1
R0: ULONGLONG = wb.GetTickCount64()
ProgRootEnter(str(AnsiDir))
ScanTree(str(AnsiDir), 1)
ProgRootLeave(str(AnsiDir), sizet(wb.GetTickCount64() - R0))
else:
# Static auto-table entries and drive letters may not exist; logging each is
# process detail, given only with --verbose (explicit --path warnings are in VDirs).
VLog.VerboseFmtS1(" skipped (missing or not a directory): %s\n", str(AnsiDir))
_mbuddy.free(rawptr(AnsiDir))
# Candidates filtered by PE/.PRVERSN: first REJECT_LOG_MAX were logged in HandleCandidate;
# only a summary line is added here (both are process detail, --verbose only).
if Reject_Total > REJECT_LOG_MAX:
VLog.VerboseFmt2(" [reject] rest omitted... (round total %u, first %u printed individually)\n",
sizet(Reject_Total), sizet(REJECT_LOG_MAX))
# Final poll before round end: no wait point before writing, so check hits enter this round's array
VGuard.PollIpc()
removed: int = VCache.ValidateCache()
VJson.EmitJson()
VLog.StdoutWrite(str(VJson.JsonBuf()))
VCache.WriteCacheFile(CachePath, VJson.JsonBuf(), sizet(VJson.JsonLen()))
T1: ULONGLONG = wb.GetTickCount64()
# Round-end line (kept by default, no paths): liveness signal, or the final line under --once.
# "Removed" = revalidation removals (Revalidated) + existence-check removals (removed).
VLog.LogFmt3("round %u end: scanned dirs %u, known entries %u",
sizet(RoundIdx), sizet(Dirs), sizet(VCache.CacheCount()))
VLog.LogFmt3(", added %u, removed %u, elapsed %u ms",
sizet(Round_Added), sizet(Revalidated + removed), sizet(T1 - T0))
# next-round-start timestamp is process detail (--verbose only)
if VLog.VerboseOn() != 0:
VLog.TimeStampAfter(Prog_Stamp, PROG_STAMP_CAP, sizet(VCli.SleepMs()))
VLog.LogFmtS1(", next round starts: %s", str(Prog_Stamp))
VLog.LogStr("\n")
return 0
# scan subcommand: args already parsed and watchdog started by App/main.vp (VCli.ParseScanArgs).
# This function only initializes and runs the resident loop (round interval --sleep, 10000; 0 = no sleep).
def RunScan() -> int:
global Pace_Enabled, Pace_Configured, Pace_Sleep_Ms, Pace_File_Units
CachePath: str = VCli.CachePath()
Once: int = VCli.Once()
# Startup summary (kept, one line): tool name + version + mode + param summary, no paths --
# it is only a "process started" signal, forming the liveness indicator with the round-end line.
VLog.LogFmtS1("VET - Viper Environment Tool %s (scan)", VCli.Version())
VLog.LogFmt2(" sleep=%u once=%u", sizet(VCli.SleepMs()), sizet(Once))
VLog.LogFmt1(" need-confirm=%u\n", sizet(VCli.NeedConfirm()))
if VCli.PaceSet() != 0:
# Explicit --pace or --pace-units => always enable throttling (even with --once), using
# command-line values; this branch precedes the --once check, so --once keeps it on.
Pace_Enabled = 1
Pace_Configured = 1
Pace_Sleep_Ms = VCli.PaceSleepMs()
Pace_File_Units = VCli.PaceFileUnits()
VLog.VerboseFmt2("--pace: scan throttling enabled sleep=%u units=%u\n",
sizet(Pace_Sleep_Ms), sizet(Pace_File_Units))
elif Once != 0:
# No explicit throttle args: --once runs one round with throttle sleep disabled
Pace_Enabled = 0
Pace_Configured = 0
VLog.VerboseStr("--once: scan throttling sleep disabled\n")
# --first-no-pace: affects round 1's actual switch only (RunScanRound restores + applies it);
# so do not set Pace_Enabled here, just log one line (process detail, --verbose only).
if VCli.FirstNoPace() != 0:
VLog.VerboseStr("--first-no-pace: round 1 runs without throttle sleep\n")
if ScanInit() == 0:
return 1
VLog.VerboseStr("[0/3] common directory table\n")
rc0: int = VDirs.BuildTable()
if rc0 == 0:
VLog.VerboseStr(" ✓ pass\n\n")
else:
# Empty table = no scan roots this round: a functional anomaly, still reported by default
VLog.LogStr("Warning: common directory table is empty (no scan roots this round)\n\n")
# Cache load moved to each round start (RunScanRound -> VCache.ReloadCache); no separate
# read here, avoiding double parsing in round 1 (--once also goes through round 1).
Round: int = 0
while True:
Round += 1
RunScanRound(CachePath, Round)
if Once != 0:
break
# --sleep: round interval (default 10000; 0 = no sleep, start next round immediately)
SleepMs: int = VCli.SleepMs()
if SleepMs > 0:
wb.Sleep(ULONG(SleepMs))
return 0
# scan query switch --search-paths: build the searchable dir table, print it line by line to
# stderr, return 0. Reuses VDirs.BuildTable; no scanning, no cache, exit code always 0.
def RunSearchPathsQuery() -> int:
VDirs.BuildTable()
Src: str = "auto table"
if VDirs.IsOverride() != 0:
Src = "override table (--path)"
VLog.LogFmtS1("[search] directory table source: %s\n", Src)
N: int = VDirs.DirCount()
if N <= 0:
# empty table must be stated explicitly so the user does not think it hung
VLog.LogStr("[search] no searchable paths (common directory table is empty)\n")
return 0
Ansi: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP)
if Ansi is None:
VLog.LogStr(" Warning: query buffer allocation failed, listing paths only (no mark for scan-skip)\n")
for i in range(N):
Path: str = VDirs.DirAt(i)
Skip: int = 0 # 1 = will be skipped when scanning; stays 0 if Ansi unavailable
if Ansi is not None:
VFs.Utf8ToAnsi(Path, Ansi, VFs.PATH_BUF_CAP)
if VFs.PathIsDir(str(Ansi)) == 0:
Skip = 1
if Skip != 0:
VLog.LogFmtIS(" [search] [%u] %s (missing or not a directory, skipped when scanning)\n", sizet(i), Path)
else:
VLog.LogFmtIS(" [search] [%u] %s\n", sizet(i), Path)
if Ansi is not None:
_mbuddy.free(rawptr(Ansi))
VLog.LogFmt1(" [search] total %u; no scan, no cache access, query ends here\n", sizet(N))
return 0