320 lines
11 KiB
Plaintext
320 lines
11 KiB
Plaintext
from stdint import *
|
|
import stdlib
|
|
import string
|
|
import memhub
|
|
import json
|
|
import lib.VFs as VFs
|
|
import lib.VLog as VLog
|
|
import win32.win32file as wf
|
|
import win32.win32base as wb
|
|
|
|
|
|
# ============================================================
|
|
# VCache - discovery cache (in-memory table + cache file read/write/parse/validation)
|
|
#
|
|
# Decoupled from VJson: WriteCacheFile takes (Buf, Len) from the caller instead of reading
|
|
# VJson's buffer; entries are exposed via CacheCount()/CacheExe(i)... to avoid an import cycle.
|
|
#
|
|
# Verbosity: load/per-entry-removal detail goes through VLog.Verbose*; parse/read fallback
|
|
# messages (text too long, pool precheck short, JSON parse failure, write failure) always print.
|
|
#
|
|
# Parsing uses the repo json library json.parse, then copies fields into the C-side table.
|
|
# Memory ownership is documented at ParseCacheText.
|
|
# ============================================================
|
|
|
|
define CACHE_CAP = 256 # cache entry limit
|
|
define CACHE_FILE_MAX = 16777216 # cache file read limit 16 MiB
|
|
define DISP_CAP = 160 # "Viper " + version string
|
|
define CACHE_TEXT_MAX = 262144 # parse text limit 256 KiB (typical ~60 KiB; larger = corrupt, start empty)
|
|
define CACHE_ARENA_PER_BYTE = 128 # parse working pool capacity per input byte (upper bound, see ParseCacheText)
|
|
define CACHE_ARENA_SLACK = 4096 # parse working pool fixed slack
|
|
|
|
# Injected by App/main.vp
|
|
_mbuddy: memhub.MemBuddy*
|
|
|
|
# Discovery cache (in-memory; ANSI paths, directly comparable to FindFirstFileA results).
|
|
# Cache_Src holds the entry source ("workspace"=scan found, "check"=check hit); its only origin.
|
|
Cache_Exe: [CACHE_CAP]str
|
|
Cache_Id: [CACHE_CAP]str
|
|
Cache_Ver: [CACHE_CAP]str
|
|
Cache_Arch: [CACHE_CAP]str
|
|
Cache_Src: [CACHE_CAP]str
|
|
Cache_Disp: [CACHE_CAP]str
|
|
Cache_Count: int = 0
|
|
|
|
# source literal: passed in by the scan side (VScan) / check side (VCheck); cache does not interpret it.
|
|
# Cache files without the source field load as SOURCE_FALLBACK, keeping existing output unchanged.
|
|
define SOURCE_FALLBACK = "workspace"
|
|
|
|
|
|
# ---- table read API (for VJson to build JSON, without exposing the arrays) ----
|
|
def CacheCount() -> int:
|
|
return Cache_Count
|
|
|
|
|
|
def CacheExe(Idx: int) -> str:
|
|
return Cache_Exe[Idx]
|
|
|
|
|
|
def CacheId(Idx: int) -> str:
|
|
return Cache_Id[Idx]
|
|
|
|
|
|
def CacheVer(Idx: int) -> str:
|
|
return Cache_Ver[Idx]
|
|
|
|
|
|
def CacheArch(Idx: int) -> str:
|
|
return Cache_Arch[Idx]
|
|
|
|
|
|
def CacheSrc(Idx: int) -> str:
|
|
return Cache_Src[Idx]
|
|
|
|
|
|
def CacheDisp(Idx: int) -> str:
|
|
return Cache_Disp[Idx]
|
|
|
|
|
|
# ============================================================
|
|
# In-memory cache
|
|
# ============================================================
|
|
|
|
def CacheFind(ExeAnsi: str) -> int:
|
|
for i in range(Cache_Count):
|
|
if string.strcmp(Cache_Exe[i], ExeAnsi) == 0:
|
|
return i
|
|
return -1
|
|
|
|
|
|
def FreeEntry(Idx: int):
|
|
_mbuddy.free(rawptr(Cache_Exe[Idx]))
|
|
_mbuddy.free(rawptr(Cache_Id[Idx]))
|
|
_mbuddy.free(rawptr(Cache_Ver[Idx]))
|
|
_mbuddy.free(rawptr(Cache_Arch[Idx]))
|
|
_mbuddy.free(rawptr(Cache_Src[Idx]))
|
|
_mbuddy.free(rawptr(Cache_Disp[Idx]))
|
|
|
|
|
|
# Remove entry Idx (frees field copies and shifts later entries left). Returns 1=removed.
|
|
# Single implementation shared by ValidateCache and VScan's per-round revalidation.
|
|
def RemoveAt(Idx: int) -> int:
|
|
global Cache_Count
|
|
if Idx < 0 or Idx >= Cache_Count:
|
|
return 0
|
|
FreeEntry(Idx)
|
|
k: int = Idx
|
|
while k < Cache_Count - 1:
|
|
Cache_Exe[k] = Cache_Exe[k + 1]
|
|
Cache_Id[k] = Cache_Id[k + 1]
|
|
Cache_Ver[k] = Cache_Ver[k + 1]
|
|
Cache_Arch[k] = Cache_Arch[k + 1]
|
|
Cache_Src[k] = Cache_Src[k + 1]
|
|
Cache_Disp[k] = Cache_Disp[k + 1]
|
|
k += 1
|
|
Cache_Count -= 1
|
|
return 1
|
|
|
|
|
|
# Clear the in-memory table (frees all field copies). Used before re-reading cache.json each round.
|
|
def ClearCache():
|
|
global Cache_Count
|
|
for j in range(Cache_Count):
|
|
FreeEntry(j)
|
|
Cache_Count = 0
|
|
|
|
|
|
# Copy one discovery record into the cache (each field duplicated). 1=ok, 0=full/alloc failure.
|
|
# Src: entry source ("workspace"/"check"), stored as-is and passed through on JSON output.
|
|
def CacheAdd(ExeAnsi: str, Id: str, Ver: str, Arch: str, Src: str, Disp: str) -> int:
|
|
global Cache_Count
|
|
if Cache_Count >= CACHE_CAP:
|
|
VLog.LogFmt1(" Warning: cache entries reached limit %d, new entry ignored\n", sizet(CACHE_CAP))
|
|
return 0
|
|
E: str = VFs.DupStr(ExeAnsi)
|
|
if E is None:
|
|
return 0
|
|
I: str = VFs.DupStr(Id)
|
|
if I is None:
|
|
_mbuddy.free(rawptr(E))
|
|
return 0
|
|
V: str = VFs.DupStr(Ver)
|
|
if V is None:
|
|
_mbuddy.free(rawptr(E))
|
|
_mbuddy.free(rawptr(I))
|
|
return 0
|
|
A: str = VFs.DupStr(Arch)
|
|
if A is None:
|
|
_mbuddy.free(rawptr(E))
|
|
_mbuddy.free(rawptr(I))
|
|
_mbuddy.free(rawptr(V))
|
|
return 0
|
|
S: str = VFs.DupStr(Src)
|
|
if S is None:
|
|
_mbuddy.free(rawptr(E))
|
|
_mbuddy.free(rawptr(I))
|
|
_mbuddy.free(rawptr(V))
|
|
_mbuddy.free(rawptr(A))
|
|
return 0
|
|
D: str = VFs.DupStr(Disp)
|
|
if D is None:
|
|
_mbuddy.free(rawptr(E))
|
|
_mbuddy.free(rawptr(I))
|
|
_mbuddy.free(rawptr(V))
|
|
_mbuddy.free(rawptr(A))
|
|
_mbuddy.free(rawptr(S))
|
|
return 0
|
|
Cache_Exe[Cache_Count] = E
|
|
Cache_Id[Cache_Count] = I
|
|
Cache_Ver[Cache_Count] = V
|
|
Cache_Arch[Cache_Count] = A
|
|
Cache_Src[Cache_Count] = S
|
|
Cache_Disp[Cache_Count] = D
|
|
Cache_Count += 1
|
|
return 1
|
|
|
|
|
|
# Drop cache entries whose exe no longer exists; returns the number removed
|
|
def ValidateCache() -> int:
|
|
removed: int = 0
|
|
j: int = 0
|
|
while j < Cache_Count:
|
|
if VFs.PathIsFile(Cache_Exe[j]) != 0:
|
|
j += 1
|
|
else:
|
|
VLog.VerboseFmtS1(" [remove] no longer exists: %s\n", Cache_Exe[j])
|
|
RemoveAt(j)
|
|
removed += 1
|
|
return removed
|
|
|
|
|
|
# ============================================================
|
|
# Cache file
|
|
# ============================================================
|
|
|
|
# Write EmitJson text (Buf, Len bytes) to the cache file (failure only warns, does not abort)
|
|
def WriteCacheFile(Path: str, Buf: bytes, Len: sizet) -> int:
|
|
if Buf is None:
|
|
return 0
|
|
rc: int = VFs.WriteFileAll(Path, Buf, Len)
|
|
if rc == 0:
|
|
VLog.LogFmt1(" Warning: cache write failed (open failed, win32 err=%u)\n", wb.GetLastError())
|
|
return 0
|
|
if rc < 0:
|
|
VLog.LogFmt2(" Warning: cache write incomplete (%zu/%zu bytes)\n", VFs.LastWriteDone, Len)
|
|
return 0
|
|
return 1
|
|
|
|
|
|
# Return the string value for Key in Obj; missing / non-string / null => empty string.
|
|
# Read-only field walk.
|
|
def FieldStr(Obj: json.JsonValue*, Key: str) -> str:
|
|
cu: json.JsonValue* = Obj.child
|
|
while cu != None:
|
|
if cu.key != None:
|
|
if string.strcmp(cu.key, Key) == 0:
|
|
if cu.vtype == json.JSON_STRING and cu.str_val != None:
|
|
return cu.str_val
|
|
return ""
|
|
cu = cu.next
|
|
return ""
|
|
|
|
|
|
# Move a parsed JSON object into the cache table (fields read out, CacheAdd makes persistent copies).
|
|
# Requires non-empty executable and id; missing/empty source => SOURCE_FALLBACK.
|
|
def AddParsedEntry(Obj: json.JsonValue*, ConvBuf: bytes) -> int:
|
|
Exe: str = FieldStr(Obj, "executable")
|
|
Id: str = FieldStr(Obj, "id")
|
|
if string.strlen(Exe) == 0 or string.strlen(Id) == 0:
|
|
return 0
|
|
Src: str = FieldStr(Obj, "source")
|
|
if string.strlen(Src) == 0:
|
|
Src = SOURCE_FALLBACK
|
|
# Match the scan side: store executable as ANSI (CacheFind compares with FindFirstFileA ANSI results)
|
|
if VFs.Utf8ToAnsi(Exe, ConvBuf, VFs.CONV_CAP) == 0:
|
|
VFs.CopyRaw(ConvBuf, VFs.CONV_CAP, Exe)
|
|
return CacheAdd(str(ConvBuf), Id, FieldStr(Obj, "version"),
|
|
FieldStr(Obj, "arch"), Src, FieldStr(Obj, "displayName"))
|
|
|
|
|
|
# Parse cache text (top-level array of objects with string fields) via json.parse; returns entries added.
|
|
# Memory: the tree lives on a per-call MemPool arena (freed whole); CacheAdd copies onto Vet_Mb.
|
|
def ParseCacheText(Txt: str) -> int:
|
|
if Txt is None:
|
|
return 0
|
|
L: sizet = string.strlen(Txt)
|
|
if L == 0:
|
|
return 0
|
|
if L > sizet(CACHE_TEXT_MAX):
|
|
VLog.LogFmt2("cache: text %zu bytes exceeds parse limit %zu, starting with empty cache\n",
|
|
L, sizet(CACHE_TEXT_MAX))
|
|
return 0
|
|
# Pool capacity: worst case ~1 JsonValue node per input byte + key/value copies + temps, scaled
|
|
# by CACHE_ARENA_PER_BYTE(128) to cover 8-byte alignment. A depleted MemPool would exit(1).
|
|
Cap: sizet = L * sizet(CACHE_ARENA_PER_BYTE) + sizet(CACHE_ARENA_SLACK)
|
|
Arena: bytes = stdlib.malloc(Cap)
|
|
if Arena is None:
|
|
VLog.LogStr("cache: parse pool allocation failed, starting with empty cache\n")
|
|
return 0
|
|
Pool: memhub.MemPool* = memhub.MemPool(Arena, Cap)
|
|
if Pool is None:
|
|
stdlib.free(rawptr(Arena))
|
|
VLog.LogStr("cache: parse pool construction failed, starting with empty cache\n")
|
|
return 0
|
|
# Pool precheck (fail-closed): the whole tree must fit, else json.parse's pool.alloc would
|
|
# exit(1). Need mirrors Cap, so this fails cleanly (empty-cache fallback) if Cap is too small.
|
|
Need: sizet = L * sizet(CACHE_ARENA_PER_BYTE) + sizet(CACHE_ARENA_SLACK)
|
|
if Pool.AllocOk(Need) == 0:
|
|
stdlib.free(rawptr(Arena))
|
|
VLog.LogFmt1("cache: parse pool precheck insufficient (worst case needs %zu bytes), starting with empty cache\n",
|
|
Need)
|
|
return 0
|
|
ConvBuf: bytes = _mbuddy.alloc(VFs.CONV_CAP)
|
|
if ConvBuf is None:
|
|
stdlib.free(rawptr(Arena))
|
|
VLog.LogStr("cache: encoding conversion buffer allocation failed, starting with empty cache\n")
|
|
return 0
|
|
Root: json.JsonValue* = json.parse(Pool, Txt)
|
|
added: int = 0
|
|
if Root == None:
|
|
VLog.LogStr("cache: JSON parse failed (corrupt content), starting with empty cache\n")
|
|
elif Root.vtype != json.JSON_ARRAY:
|
|
VLog.LogStr("cache: top level is not a JSON array, starting with empty cache\n")
|
|
else:
|
|
cu: json.JsonValue* = Root.child
|
|
while cu != None:
|
|
if cu.vtype == json.JSON_OBJECT:
|
|
added += AddParsedEntry(cu, ConvBuf)
|
|
cu = cu.next
|
|
_mbuddy.free(rawptr(ConvBuf))
|
|
stdlib.free(rawptr(Arena))
|
|
return added
|
|
|
|
|
|
# Read the cache as a seed at startup. Missing/unreadable/unparsable are non-fatal: warn, start empty.
|
|
def LoadCache(Path: str) -> int:
|
|
buf: bytes = VFs.ReadWholeFile(Path, sizet(CACHE_FILE_MAX),
|
|
wf.FILE_SHARE_READ | wf.FILE_SHARE_WRITE)
|
|
if buf is None:
|
|
rc_err: int = VFs.LastReadErr
|
|
if rc_err == 1:
|
|
VLog.VerboseFmtS1("cache: not found / cannot open, starting with empty cache: %s\n", Path)
|
|
elif rc_err == 2:
|
|
VLog.LogStr("cache: read size failed, starting with empty cache\n")
|
|
elif rc_err == 5:
|
|
VLog.LogStr("cache: buffer allocation failed, starting with empty cache\n")
|
|
else:
|
|
VLog.LogStr("cache: size invalid / too large, starting with empty cache\n")
|
|
return 0
|
|
n: int = ParseCacheText(str(buf))
|
|
stdlib.free(rawptr(buf))
|
|
VLog.VerboseFmt1("cache: loaded %u seed entries\n", sizet(n))
|
|
return n
|
|
|
|
|
|
# Re-read cache.json at round start: clear the in-memory table, then rebuild from the file.
|
|
# Returns entries loaded; missing/unreadable/unparsable continue empty (LoadCache warns internally).
|
|
def ReloadCache(Path: str) -> int:
|
|
ClearCache()
|
|
return LoadCache(Path)
|