Private/AntiVirus.psm1


using namespace System
using namespace System.IO
using namespace System.Runtime.InteropServices
using namespace System.Threading

using module .\Enums.psm1
using module .\COMInterop.psm1

class AVScanResult : System.IComparable, System.IEquatable[Object] {
  [bool]$Passed
  [int]$ErrorCode
  [string]$EngineName
  [AVScanResultType]$ResType = "VirusNotFound"
  [AllowNull()][string]$AdditionalMessage

  AVScanResult() {}
  AVScanResult([AVScanResultType]$ResType) {
    $this.ResType = $ResType
  }

  # Override base Object.Equals
  [bool] Equals([object]$obj) {
    if ($null -eq $obj) { return $false }
    if ($obj -is [AVScanResult]) {
      return $this.ErrorCode -eq $obj.ErrorCode
    }
    return $false
  }
  # Override GetHashCode
  [int] GetHashCode() {
    return $this.ResType.GetHashCode()
  }
  [Int] CompareTo($rhs) {
    if ($rhs -isnot [AVScanResult]) {
      throw "NotIcomparable"
    } else {
      return $this.GetHashCode() - $rhs.GetHashCode()
    }
  }
  [string] ToString() {
    return $this.ResType.ToString()
  }
}

# ---------------------------------------------------------------------------
# AVScanner
# ---------------------------------------------------------------------------
# Wraps the Windows IAttachmentExecute COM API to scan a file with whatever
# antivirus product is installed on the local machine (Windows Defender, etc.).
#
# Requirements:
# - Windows OS (7+)
# - An installed AV product that supports the IAttachmentExecute COM API
#
# Usage:
# $scanner = [AvScanner]::new()
# $result = $scanner.ScanAndClean("C:\path\to\file.exe")
# # $result is an [AVScanResultType]: VirusNotFound | VirusFound | FileNotExist | BlockedByPolicy
#
# ---------------------------------------------------------------------------

class AvScanner {
  ## The client GUID registered with the IAttachmentExecute API.
  [Guid]$ClientGuid
  AvScanner() {
    ## Initializes Scanner with the default AntiVirusScanner project GUID.
    $this.ClientGuid = [Guid]::new("{C467440F-8ACB-449B-A7B1-05B7405C3753}")
  }

  ## Initializes Scanner with a custom GUID.
  AvScanner([Guid]$clientGuid) {
    $this.ClientGuid = $clientGuid
  }

  ## Initializes Scanner from a GUID string.
  AvScanner([string]$clientGuidString) {
    $this.ClientGuid = [Guid]::new($clientGuidString)
  }

  # -------------------------------------------------------------------------
  # Public methods
  # -------------------------------------------------------------------------

  ## Scans the specified file and attempts to clean it if infected.
  ## Returns [AVScanResultType]: VirusNotFound | VirusFound | FileNotExist | BlockedByPolicy
  [AVScanResultType] ScanAndClean([string]$path) {
    if (-not [IO.Path]::IsPathRooted($path)) {
      throw [ArgumentException]::new("Path is not rooted.", "path")
    }
    # IAttachmentExecute must run on an STA thread.
    # In PS 7+ (MTA default) we spin up a dedicated STA thread.
    if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -eq [System.Threading.ApartmentState]::STA) {
      return ($this._ScanAndCleanCore($path)).ResType
    } else {
      return $this._RunOnSTAThread($path)
    }
  }

  ## Pipeline-friendly overload that accepts a FileInfo object.
  [AVScanResultType] ScanAndClean([IO.FileInfo]$fileInfo) {
    return $this.ScanAndClean($fileInfo.FullName)
  }

  ## Convenience static factory: create a Scanner and scan in one call.
  static [AVScanResultType] Scan([string]$path) {
    return [AvScanner]::new().ScanAndClean($path)
  }

  ## Returns a human-readable description for an [AVScanResultType] value.
  static [string] Describe([AVScanResultType]$result) {
    $map = @{
      [AVScanResultType]::VirusNotFound   = "No virus was detected."
      [AVScanResultType]::VirusFound      = "A virus was detected. The file may have been cleaned or quarantined."
      [AVScanResultType]::FileNotExist    = "The specified file does not exist."
      [AVScanResultType]::BlockedByPolicy = "The file was blocked by security policy."
    }
    $desc = $map[[AVScanResultType]$result]
    if ($null -ne $desc) { return $desc }
    return "Unknown scan result: $result"
  }

  # -------------------------------------------------------------------------
  # Hidden / internal helpers
  # -------------------------------------------------------------------------

  ## Searches all loaded assemblies in the current AppDomain for a type by
  ## its full name. This is needed because Add-Type compiles into an anonymous
  ## dynamic assembly whose name cannot be used with [type]::GetType(string).
  ## We CANNOT use [AntiVirus.COMInterop.*] type literals in PS class bodies
  ## because the types don't exist yet at parse/compile time (using module).
  hidden static [type] _FindType([string]$fullName) {
    foreach ($asm in [AppDomain]::CurrentDomain.GetAssemblies()) {
      $t = $asm.GetType($fullName)
      if ($null -ne $t) { return $t }
    }
    throw [InvalidOperationException]::new(
      "Could not find type '$fullName' in any loaded assembly. " +
      "Ensure COMInterop.psm1 has been imported and Add-Type completed successfully."
    )
  }

  ## Core scanning logic – must be called from an STA thread.
  ## Delegates the actual COM work to AntiVirus.COMInterop.AttachmentScanner.ScanFile()
  ## We invoke it through reflection so that no [AntiVirus.COMInterop.*] type
  ## literals appear in this PS class body (they would fail at parse time).
  hidden [AVScanResult] _ScanAndCleanCore([string]$path) {
    # Invoke static ScanFile(Guid clientGuid, string path) → int (HRESULT)
    [int]$hresult = [AttachmentScannerCOM]::ScanFile($this.ClientGuid, $path)

    [AVScanResultType]$ResultType = switch ($hresult) {
      0          { "VirusNotFound"; break }  # S_OK
      -2146631666 { "BlockedByPolicy"; break }  # 0x800C000E: INET_E_SECURITY_PROBLEM
      -2147024894 { "FileNotExist"; break }  # 0x80070002: ERROR_FILE_NOT_FOUND or 0x80070057 in some cases
      -2147024809 { "FileNotExist"; break }  # 0x80070057: E_INVALIDARG (often returned when file missing for this API)
      -2147467259 { "VirusFound"; break }  # 0x80004005: E_FAIL
      default {
        throw [Runtime.InteropServices.COMException]::new(
          ("Unexpected HRESULT from IAttachmentExecute.Save(): 0x{0:X8} ($hresult)" -f $hresult),
          $hresult
        )
      }
    }

    # Unreachable – satisfies PowerShell's return-type requirement.
    return [AVScanResult]::new($ResultType)
  }

  ## Spins up a dedicated STA thread to run _ScanAndCleanCore(), then
  ## surfaces the result or re-throws any exception on the calling thread.
  hidden [AVScanResult] _RunOnSTAThread([string]$path) {
    $self = $this
    $capturedPath = $path
    $resultRef = [ref][AVScanResultType]::VirusNotFound
    $exceptionRef = [ref]$null

    $staThread = [System.Threading.Thread]::new(
      [ThreadStart] {
        try {
          $resultRef.Value = $self._ScanAndCleanCore($capturedPath)
        } catch {
          $exceptionRef.Value = $_.Exception
        }
      }
    )
    $staThread.SetApartmentState([ApartmentState]::STA)
    $staThread.Start()
    $staThread.Join()

    if ($null -ne $exceptionRef.Value) {
      throw [Exception]::new(
        "Unexpected exception on STA scanning thread: $($exceptionRef.Value.Message)",
        $exceptionRef.Value
      )
    }
    return $resultRef.Value.ResType
  }
  static [void] RunTestScans() {

    Write-Host "`n=== AntiVirus Scanner Tests ===" -ForegroundColor Cyan

    # Test 1: Basic construction
    $s = [AvScanner]::new()
    Write-Host "[PASS] Scanner instantiated. ClientGuid: $($s.ClientGuid)" -ForegroundColor Green

    # Test 2: Custom GUID constructor
    $customGuid = [Guid]::NewGuid()
    $s2 = [AvScanner]::new($customGuid)
    Write-Host "[PASS] Custom-GUID Scanner: $($s2.ClientGuid)" -ForegroundColor Green

    # Test 3: String GUID constructor
    $s3 = [AvScanner]::new("{C467440F-8ACB-449B-A7B1-05B7405C3753}")
    Write-Host "[PASS] String-GUID Scanner: $($s3.ClientGuid)" -ForegroundColor Green

    # Test 4: Describe static method
    foreach ($val in [Enum]::GetValues([AVScanResultType])) {
      $desc = [AvScanner]::Describe($val)
      Write-Host "[PASS] Describe($val): $desc" -ForegroundColor Green
    }

    # Test 5: FileNotExist path
    $fakePath = "C:\does_not_exist_12345.txt"
    Write-Host "`nScanning non-existent file: $fakePath" -ForegroundColor Yellow
    try {
      $result = [AvScanner]::Scan($fakePath)
      Write-Host "[RESULT] $result" -ForegroundColor Cyan
    } catch {
      Write-Host "[INFO] Scan threw: $($_.Exception.Message)" -ForegroundColor Yellow
    }

    # Test 6: Rooted path check
    Write-Host "`nTesting non-rooted path guard..." -ForegroundColor Yellow
    try {
      $s.ScanAndClean("relative\path.txt")
      Write-Host "[FAIL] Should have thrown!" -ForegroundColor Red
    } catch [ArgumentException] {
      Write-Host "[PASS] Correctly rejected non-rooted path: $($_.Exception.Message)" -ForegroundColor Green
    }

    # Test 7: Scan a real safe file (notepad.exe)
    $notepad = "C:\Windows\System32\notepad.exe"
    if (Test-Path $notepad) {
      Write-Host "`nScanning: $notepad" -ForegroundColor Yellow
      $result = $s.ScanAndClean($notepad)
      Write-Host "[RESULT] $result -> $([AvScanner]::Describe($result))" -ForegroundColor Cyan
    }
    Write-Host "`n=== All tests complete ===" -ForegroundColor Cyan
  }
}