Skip to content

Commit 4dae5cf

Browse files
authored
fix(windows): NuGet ONNX scan, LoadLibraryExW validation, storage mkdir, file-replacement retry (#66)
Windows hardening contributed by @Zireael in PR #66. Closes the remaining Windows sub-issues from #35 and #64: - ONNX Runtime discovery now scans NuGet Microsoft.ML.OnnxRuntime cache paths in addition to system installs, with case-insensitive dedup - LoadLibraryExW validation on Windows confirms the resolved ONNX library actually loads before AFT commits to it - 'aft-bridge' downloader handles EEXIST on Windows by unlinking before retry, fixing rare cache-write races - 'configure' creates storage directories with 'fs::create_dir_all' so first-run on Windows can't fail on missing parents - Semantic-index file replacement uses rename-with-retry to tolerate transient Windows file locks (AV scanners, Defender) - CLI 'diagnostics' creates its storage subdirectory before writing, matching the bridge behavior - PATH parsing in both bridge and CLI shares 'pathEntriesForPlatform' helper that rejects empty/'.'/null-byte/relative entries, preserving the v0.26.1 Windows DLL-hijacking defense
1 parent ee0694f commit 4dae5cf

7 files changed

Lines changed: 301 additions & 44 deletions

File tree

crates/aft/src/commands/configure.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::fs;
12
use std::panic::{catch_unwind, AssertUnwindSafe};
23
use std::path::{Component, Path, PathBuf};
34
use std::process::Command;
@@ -1365,6 +1366,19 @@ pub fn handle_configure(req: &RawRequest, ctx: &AppContext) -> Response {
13651366
&canonical_cache_root,
13661367
));
13671368
if let Some(storage_dir) = next_config.storage_dir.clone() {
1369+
// Ensure the storage root directory exists so subsystems (trust,
1370+
// backups, checkpoints, DB, persistence) can create their sub-trees
1371+
// without a separate create_dir_all per subsystem. On fresh installs
1372+
// this directory hasn't been created yet, and every subsystem
1373+
// currently creates its own subdirectory lazily — but the root must
1374+
// exist for status/diagnostics to report a valid path.
1375+
if let Err(err) = fs::create_dir_all(&storage_dir) {
1376+
slog_warn!(
1377+
"failed to create storage directory {}: {}",
1378+
storage_dir.display(),
1379+
err
1380+
);
1381+
}
13681382
ctx.backup().borrow_mut().set_storage_dir_for_harness(
13691383
storage_dir,
13701384
harness,

crates/aft/src/semantic_index.rs

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ const F32_BYTES: usize = std::mem::size_of::<f32>();
3030
const HEADER_BYTES_V1: usize = 9;
3131
const HEADER_BYTES_V2: usize = 13;
3232
const ONNX_RUNTIME_INSTALL_HINT: &str =
33-
"ONNX Runtime not found. Install via: brew install onnxruntime (macOS) or apt install libonnxruntime (Linux).";
33+
"ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
34+
apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
35+
AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
3436

3537
const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
3638
const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
@@ -788,8 +790,136 @@ pub fn pre_validate_onnx_runtime() -> Result<(), String> {
788790

789791
#[cfg(target_os = "windows")]
790792
{
791-
// On Windows, skip pre-validation — let ort handle LoadLibrary
792-
let _ = dylib_path;
793+
// Validate ONNX Runtime availability on Windows by loading the DLL
794+
// via LoadLibraryExW before the ort crate attempts its own LoadLibrary.
795+
// This way we can produce a friendly error (with installation hints)
796+
// instead of a raw LoadLibrary failure from deep inside fastembed.
797+
let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
798+
799+
// Use kernel32 LoadLibraryExW for the validation — built-in, no
800+
// crate dependency required. GetModuleFileNameW resolves the loaded
801+
// DLL path for version probing via the version.dll API.
802+
#[link(name = "kernel32")]
803+
extern "system" {
804+
fn LoadLibraryExW(
805+
lpLibFileName: *const u16,
806+
hFile: *mut std::ffi::c_void,
807+
dwFlags: u32,
808+
) -> *mut std::ffi::c_void;
809+
fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
810+
fn GetModuleFileNameW(
811+
hModule: *mut std::ffi::c_void,
812+
lpFilename: *mut u16,
813+
nSize: u32,
814+
) -> u32;
815+
}
816+
817+
#[link(name = "version")]
818+
extern "system" {
819+
fn GetFileVersionInfoSizeW(
820+
lptstrFilename: *const u16,
821+
lpdwHandle: *mut u32,
822+
) -> u32;
823+
fn GetFileVersionInfoW(
824+
lptstrFilename: *const u16,
825+
dwHandle: u32,
826+
dwLen: u32,
827+
lpData: *mut std::ffi::c_void,
828+
) -> i32;
829+
fn VerQueryValueW(
830+
pBlock: *mut std::ffi::c_void,
831+
lpSubBlock: *const u16,
832+
lplpBuffer: *mut *mut std::ffi::c_void,
833+
puLen: *mut u32,
834+
) -> i32;
835+
}
836+
837+
#[repr(C)]
838+
struct VS_FIXEDFILEINFO {
839+
dw_signature: u32,
840+
dw_struc_version: u32,
841+
dw_file_version_ms: u32, // HIWORD major, LOWORD minor
842+
dw_file_version_ls: u32, // HIWORD build, LOWORD revision
843+
dw_product_version_ms: u32,
844+
dw_product_version_ls: u32,
845+
dw_file_flags_mask: u32,
846+
dw_file_flags: u32,
847+
dw_file_os: u32,
848+
dw_file_type: u32,
849+
dw_file_subtype: u32,
850+
dw_file_date_ms: u32,
851+
dw_file_date_ls: u32,
852+
}
853+
854+
unsafe {
855+
use std::os::windows::ffi::OsStrExt;
856+
let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
857+
.encode_wide()
858+
.chain(std::iter::once(0))
859+
.collect();
860+
861+
let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
862+
if handle.is_null() {
863+
let err = std::io::Error::last_os_error();
864+
return Err(format!(
865+
"ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
866+
Run `npx @cortexkit/aft doctor` to diagnose.",
867+
lib_name, err
868+
));
869+
}
870+
871+
// Probe the file version from PE resources so we can reject
872+
// outdated DLLs (e.g. v1.9.x) before the ort crate panics.
873+
let mut detected_major: u32 = 0;
874+
let mut detected_minor: u32 = 0;
875+
let mut path_buf = [0u16; 260];
876+
let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 260);
877+
if path_len > 0 {
878+
let mut dummy_handle: u32 = 0;
879+
let info_size =
880+
GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
881+
if info_size > 0 {
882+
let mut info = vec![0u8; info_size as usize];
883+
if GetFileVersionInfoW(
884+
path_buf.as_ptr(),
885+
0,
886+
info_size,
887+
info.as_mut_ptr() as *mut std::ffi::c_void,
888+
) != 0
889+
{
890+
let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
891+
let mut vs_info: *mut std::ffi::c_void =
892+
std::ptr::null_mut();
893+
let mut vs_len: u32 = 0;
894+
if VerQueryValueW(
895+
info.as_mut_ptr() as *mut std::ffi::c_void,
896+
sub_block.as_ptr(),
897+
&mut vs_info,
898+
&mut vs_len,
899+
) != 0
900+
&& !vs_info.is_null()
901+
{
902+
let fixed = vs_info as *const VS_FIXEDFILEINFO;
903+
detected_major = (*fixed).dw_file_version_ms >> 16;
904+
detected_minor =
905+
(*fixed).dw_file_version_ms & 0xFFFF;
906+
}
907+
}
908+
}
909+
}
910+
911+
FreeLibrary(handle);
912+
913+
// Version compatibility check (mirrors the Linux/macOS path).
914+
// If version could not be detected (detected_major == 0) we let
915+
// the load succeed — the ort crate will diagnose further.
916+
if detected_major != 0
917+
&& (detected_major != 1 || detected_minor < 20)
918+
{
919+
let ver = format!("{}.{}", detected_major, detected_minor);
920+
return Err(format_ort_version_mismatch(&ver, lib_name));
921+
}
922+
}
793923
}
794924

795925
Ok(())
@@ -839,7 +969,6 @@ fn extract_version_from_filename(name: &str) -> Option<String> {
839969
re.find(name).map(|m| m.as_str().to_string())
840970
}
841971

842-
#[cfg(any(test, target_os = "linux", target_os = "macos"))]
843972
fn suggest_removal_command(lib_path: &str) -> String {
844973
if lib_path.starts_with("/usr/local/lib")
845974
|| lib_path == "libonnxruntime.so"
@@ -860,7 +989,6 @@ fn suggest_removal_command(lib_path: &str) -> String {
860989
/// stability — the auto-fix recommendation must always come first because
861990
/// it's the only safe option, and the system-rm step must remain present
862991
/// because some users prefer the system-wide cleanup path.
863-
#[cfg(any(test, target_os = "linux", target_os = "macos"))]
864992
pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
865993
format!(
866994
"ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \

packages/aft-bridge/src/downloader.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,16 @@ export async function downloadBinary(version?: string): Promise<string | null> {
269269
chmodSync(tmpPath, 0o755);
270270
}
271271

272-
// Atomic rename
272+
// Replace binary — on Windows renameSync fails (EEXIST) when the target
273+
// exists, so unlink first. This creates a brief window where no binary
274+
// exists at binaryPath — callers should handle a missing binary gracefully.
275+
if (process.platform === "win32" && existsSync(binaryPath)) {
276+
try {
277+
unlinkSync(binaryPath);
278+
} catch {
279+
// best-effort; renameSync will surface the error if unlink fails
280+
}
281+
}
273282
renameSync(tmpPath, binaryPath);
274283

275284
log(`AFT binary ready at ${binaryPath}`);

packages/aft-bridge/src/onnx-runtime.ts

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -459,36 +459,89 @@ function findSystemOnnxRuntime(libName?: string): string | null {
459459
"/usr/local/lib",
460460
);
461461
} else if (process.platform === "win32") {
462-
// Windows users often install ONNX Runtime via Scoop or a manual zip and
463-
// put the directory containing `onnxruntime.dll` on PATH. We only consider
464-
// absolute PATH entries (never the current directory or relative paths).
465-
// Returning one of those directories means the plugin may pass it to Rust
466-
// as the ORT DLL directory; that mirrors Windows loader semantics for a
467-
// user-controlled PATH while avoiding accidental current-dir DLL loads.
468-
// Doctor-only probes may be looser, but the bridge path can become a real
469-
// load path and should stay conservative.
462+
// Common Windows install locations for ONNX Runtime
463+
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
464+
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
465+
searchPaths.push(
466+
join(programFiles, "onnxruntime", "lib"),
467+
join(programFiles, "Microsoft ONNX Runtime", "lib"),
468+
join(programFiles, "Microsoft Machine Learning", "lib"),
469+
join(programFilesX86, "onnxruntime", "lib"),
470+
// Windows NuGet package layout:
471+
// <user>\.nuget\packages\microsoft.ml.onnxruntime\<version>\runtimes\win-{x64,arm64}\native\
472+
// Scan all installed versions since we don't know which one is present.
473+
...(() => {
474+
const nugetPaths: string[] = [];
475+
const userProfile = process.env.USERPROFILE ?? "";
476+
if (!userProfile) return nugetPaths;
477+
const nugetPackageDir = join(userProfile, ".nuget", "packages", "microsoft.ml.onnxruntime");
478+
if (!existsSync(nugetPackageDir)) return nugetPaths;
479+
try {
480+
for (const entry of readdirSync(nugetPackageDir, { withFileTypes: true })) {
481+
if (!entry.isDirectory()) continue;
482+
// Skip well-known non-version entries
483+
if (entry.name === "__globalPackagesFolder" || entry.name.startsWith(".")) continue;
484+
nugetPaths.push(
485+
join(nugetPackageDir, entry.name, "runtimes", "win-x64", "native"),
486+
join(nugetPackageDir, entry.name, "runtimes", "win-arm64", "native"),
487+
);
488+
}
489+
} catch {
490+
// best-effort scan
491+
}
492+
return nugetPaths;
493+
})(),
494+
);
495+
// Also include absolute PATH entries (reuses the existing helper that
496+
// validates absolute paths, rejects null bytes, strips quotes, excludes ".").
470497
searchPaths.push(...pathEntriesForPlatform());
471498
}
472499

500+
// Deduplicate paths while preserving order.
501+
// On case-insensitive filesystems (Windows, macOS) normalize casing for
502+
// comparison; on Linux the raw path casing is the authority.
503+
const normalizeCase = process.platform === "win32" || process.platform === "darwin";
473504
const seen = new Set<string>();
474-
for (const dir of searchPaths) {
475-
if (seen.has(dir)) continue;
476-
seen.add(dir);
477-
if (!directoryContainsLibrary(dir, libName)) continue;
478-
479-
// Reject system installs that the Rust pre-validator will refuse. Without
480-
// this filter, a stale distro package (e.g. libonnxruntime1.9 on Ubuntu
481-
// 22.04) shadows our auto-downloaded v1.24 forever and semantic search
482-
// stays "failed" until the user hand-deletes the system library.
483-
const version = detectOnnxVersion(dir, libName);
484-
if (version && !isOnnxVersionCompatible(version)) {
485-
warn(
486-
`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` +
487-
`v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`,
488-
);
505+
const uniquePaths = searchPaths.filter((p) => {
506+
let key = resolve(p).replace(/[/\\]+$/, "");
507+
if (normalizeCase) key = key.toLowerCase();
508+
if (seen.has(key)) return false;
509+
seen.add(key);
510+
return true;
511+
});
512+
513+
for (const dir of uniquePaths) {
514+
const libPath = join(dir, libName);
515+
if (process.platform === "win32") {
516+
if (!directoryContainsLibrary(dir, libName)) continue;
517+
} else if (!existsSync(libPath)) {
489518
continue;
490519
}
491520

521+
// Skip the version check for PATH entries — version-suffixed filenames
522+
// are less common on Windows and we want PATH discovery to succeed.
523+
let skipVersionCheck = false;
524+
if (process.platform === "win32") {
525+
// Only do version check for common install paths, not arbitrary PATH entries
526+
const isCommonPath = dir.includes("Program Files") || dir.includes("onnxruntime");
527+
if (!isCommonPath) skipVersionCheck = true;
528+
}
529+
530+
if (!skipVersionCheck) {
531+
// Reject system installs that the Rust pre-validator will refuse. Without
532+
// this filter, a stale distro package (e.g. libonnxruntime1.9 on Ubuntu
533+
// 22.04) shadows our auto-downloaded v1.24 forever and semantic search
534+
// stays "failed" until the user hand-deletes the system library.
535+
const version = detectOnnxVersion(dir, libName);
536+
if (version && !isOnnxVersionCompatible(version)) {
537+
warn(
538+
`Skipping system ONNX Runtime at ${dir} (v${version}); AFT requires ` +
539+
`v${REQUIRED_ORT_MAJOR}.${REQUIRED_ORT_MIN_MINOR}+. Falling through to AFT-managed download.`,
540+
);
541+
continue;
542+
}
543+
}
544+
492545
return dir;
493546
}
494547

packages/aft-bridge/src/resolver.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync } from "node:child_process";
2-
import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2+
import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
33
import { createRequire } from "node:module";
44
import { homedir } from "node:os";
55
import { join } from "node:path";
@@ -55,6 +55,14 @@ function copyToVersionedCache(npmBinaryPath: string, knownVersion?: string): str
5555
if (process.platform !== "win32") {
5656
chmodSync(tmpPath, 0o755);
5757
}
58+
// Best-effort replace — unlink first on Windows where renameSync fails if target exists
59+
if (process.platform === "win32" && existsSync(cachedPath)) {
60+
try {
61+
unlinkSync(cachedPath);
62+
} catch {
63+
// best-effort; renameSync will surface the error if unlink fails
64+
}
65+
}
5866
renameSync(tmpPath, cachedPath);
5967
log(`Copied npm binary to versioned cache: ${cachedPath}`);
6068
return cachedPath;

packages/aft-cli/src/lib/diagnostics.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
1+
import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
22
import type { HarnessAdapter } from "../adapters/types.js";
33
import { type BinaryCacheInfo, getBinaryCacheInfo } from "./binary-cache.js";
44
import { probeBinaryVersion } from "./binary-probe.js";
@@ -113,6 +113,18 @@ async function diagnoseHarness(adapter: HarnessAdapter): Promise<HarnessDiagnost
113113
const logPath = adapter.getLogFile();
114114
const pluginCache = adapter.getPluginCacheInfo();
115115

116+
// Ensure the storage directory exists so diagnostics report useful info
117+
// instead of "(not created)" on fresh installs. The storage directory is
118+
// normally created lazily by the bridge on first tool call, but the doctor
119+
// command is a read-only diagnostic that should still display it.
120+
if (!existsSync(storage)) {
121+
try {
122+
mkdirSync(storage, { recursive: true });
123+
} catch {
124+
// best-effort — diagnostics can still report the path as (not created)
125+
}
126+
}
127+
116128
const describeStorage =
117129
"describeStorageSubtrees" in adapter &&
118130
typeof (adapter as unknown as { describeStorageSubtrees: () => Record<string, number> })

0 commit comments

Comments
 (0)