feat(binary): add engine selection for binary analysis

This commit is contained in:
Rodrigo Verdiani 2026-06-09 20:25:14 -03:00
parent decb831ba1
commit cd208b51fc
11 changed files with 400 additions and 38 deletions

View File

@ -4,6 +4,12 @@
* OpenAPI definition
* OpenAPI spec version: v0
*/
export interface EngineResponse {
name: string;
label: string;
available: boolean;
}
export interface WorkspaceRequest {
/** @minLength 1 */
name: string;

View File

@ -0,0 +1,120 @@
/**
* Generated by orval v8.15.0 🍺
* Do not edit manually.
* OpenAPI definition
* OpenAPI spec version: v0
*/
import { useQuery } from "@tanstack/react-query";
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type { EngineResponse } from "../api.schemas";
import { customInstance } from "../../api";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Retrieve all registered static analysis engines with their availability status.
* @summary List available analysis engines
*/
export const listEngines = (
options?: SecondParameter<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<EngineResponse[]>({ url: `/engines`, method: "GET", signal }, options);
};
export const getListEnginesQueryKey = () => {
return [`/engines`] as const;
};
export const getListEnginesQueryOptions = <
TData = Awaited<ReturnType<typeof listEngines>>,
TError = unknown,
>(options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listEngines>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListEnginesQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listEngines>>> = ({ signal }) =>
listEngines(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listEngines>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ListEnginesQueryResult = NonNullable<Awaited<ReturnType<typeof listEngines>>>;
export type ListEnginesQueryError = unknown;
export function useListEngines<TData = Awaited<ReturnType<typeof listEngines>>, TError = unknown>(
options: {
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof listEngines>>, TError, TData>> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listEngines>>,
TError,
Awaited<ReturnType<typeof listEngines>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListEngines<TData = Awaited<ReturnType<typeof listEngines>>, TError = unknown>(
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listEngines>>, TError, TData>> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listEngines>>,
TError,
Awaited<ReturnType<typeof listEngines>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListEngines<TData = Awaited<ReturnType<typeof listEngines>>, TError = unknown>(
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listEngines>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
/**
* @summary List available analysis engines
*/
export function useListEngines<TData = Awaited<ReturnType<typeof listEngines>>, TError = unknown>(
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listEngines>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListEnginesQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
return { ...query, queryKey: queryOptions.queryKey };
}

View File

@ -0,0 +1,57 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import type { EngineResponse } from "@/api/generated/api.schemas";
import { useMemo } from "react";
interface EngineSelectProps {
engines: EngineResponse[];
value?: string;
onChange: (engine: string) => void;
placeholder?: string;
isLoading?: boolean;
disabled?: boolean;
}
export const EngineSelect = ({
engines,
value,
onChange,
placeholder = "Select engine",
isLoading = false,
disabled = false,
}: EngineSelectProps) => {
const available = useMemo(() => engines.filter((e) => e.available), [engines]);
return (
<Select value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger className="w-full">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground">Loading engines...</span>
</div>
) : (
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
<SelectContent>
{available.length === 0 && !isLoading && (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
No engines available
</div>
)}
{available.map((engine) => (
<SelectItem key={engine.name} value={engine.name}>
{engine.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -109,7 +109,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm py-1.5 pr-2 pl-3 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:cursor-default data-[disabled]:opacity-50 data-[state=checked]:pl-8",
className
)}
{...props}

View File

@ -0,0 +1,96 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { EngineSelect } from "@/components/EngineSelect";
import { AlertTriangle } from "lucide-react";
import type { EngineResponse } from "@/api/generated/api.schemas";
interface AnalyzeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isReanalyze: boolean;
engines: EngineResponse[];
enginesLoading: boolean;
onConfirm: (engine: string) => void;
}
export const AnalyzeDialog = ({
open,
onOpenChange,
isReanalyze,
engines,
enginesLoading,
onConfirm,
}: AnalyzeDialogProps) => {
const [selectedEngine, setSelectedEngine] = useState<string>();
const handleConfirm = () => {
if (!selectedEngine) return;
onConfirm(selectedEngine);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
{isReanalyze && (
<div className="flex items-center gap-3">
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-full">
<AlertTriangle className="text-destructive h-5 w-5" />
</div>
<div>
<DialogTitle>Re-analyze binary</DialogTitle>
<DialogDescription className="mt-1">
This will replace all current analysis data. Function names, comments, and
decompiled code will be lost.
</DialogDescription>
</div>
</div>
)}
{!isReanalyze && (
<div>
<DialogTitle>Analyze binary</DialogTitle>
<DialogDescription className="mt-1">
Select the analysis engine to use.
</DialogDescription>
</div>
)}
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Analysis Engine</label>
<EngineSelect
engines={engines}
value={selectedEngine}
onChange={setSelectedEngine}
isLoading={enginesLoading}
placeholder="Select an engine"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={isReanalyze ? "destructive" : "default"}
onClick={handleConfirm}
disabled={!selectedEngine}
>
{isReanalyze ? "Re-analyze" : "Analyze"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -6,6 +6,7 @@ import { useGetProject } from "@/api/generated/project/project.ts";
import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts";
import { useListAnalyses, useListSegments } from "@/api/generated/analysis/analysis.ts";
import { useGetFunction } from "@/api/generated/function/function.ts";
import { useListEngines } from "@/api/generated/engine/engine.ts";
import { customInstance } from "@/api/api.ts";
import type {
FunctionSummaryResponse,
@ -42,7 +43,7 @@ export function useBinaryPage() {
const queryClient = useQueryClient();
const [selectedFunctionId, setSelectedFunctionId] = useState<string>();
const [confirmReanalyzeOpen, setConfirmReanalyzeOpen] = useState(false);
const [analyzeDialogOpen, setAnalyzeDialogOpen] = useState(false);
const [selectedSegmentId, setSelectedSegmentId] = useState<string>();
const [selectedFlags, setSelectedFlags] = useState<Set<string>>(new Set());
@ -96,6 +97,9 @@ export function useBinaryPage() {
{ query: { enabled: !!selectedFunctionId } }
);
// Fetch available engines
const { data: engines, isLoading: enginesLoading } = useListEngines();
// Analyze / re-analyze mutation
const analyzeMutation = useAnalyzeBinary({
mutation: {
@ -107,17 +111,13 @@ export function useBinaryPage() {
},
});
const handleAnalyze = () => {
analyzeMutation.mutate({ binaryId, params: { engine: "IDA5" } });
const handleOpenAnalyzeDialog = () => {
setAnalyzeDialogOpen(true);
};
const handleReanalyzeRequest = () => {
setConfirmReanalyzeOpen(true);
};
const handleReanalyzeConfirm = () => {
setConfirmReanalyzeOpen(false);
handleAnalyze();
const handleAnalyzeConfirm = (engine: string) => {
setAnalyzeDialogOpen(false);
analyzeMutation.mutate({ binaryId, params: { engine } });
};
// Flatten all pages into a single array
@ -217,10 +217,11 @@ export function useBinaryPage() {
selectedFunction,
selectedFunctionLoading,
analyzeMutation,
confirmReanalyzeOpen,
setConfirmReanalyzeOpen,
handleAnalyze,
handleReanalyzeRequest,
handleReanalyzeConfirm,
analyzeDialogOpen,
setAnalyzeDialogOpen,
handleOpenAnalyzeDialog,
handleAnalyzeConfirm,
engines: engines ?? [],
enginesLoading,
};
}

View File

@ -13,6 +13,8 @@ interface FileUploadProps {
accept?: string;
maxSize?: number;
className?: string;
autoUpload?: boolean;
onFileSelect?: (file: File) => void;
}
export const FileUpload = ({
@ -23,6 +25,8 @@ export const FileUpload = ({
accept = ".exe,.com,.dll,.bin",
maxSize = 50 * 1024 * 1024,
className,
autoUpload = true,
onFileSelect,
}: FileUploadProps) => {
const [isDragging, setIsDragging] = useState(false);
const [file, setFile] = useState<File | null>(null);
@ -49,9 +53,13 @@ export const FileUpload = ({
setValidationError(null);
setFile(file);
if (autoUpload) {
onUpload(file);
} else {
onFileSelect?.(file);
}
},
[onUpload, maxSize]
[onUpload, maxSize, autoUpload, onFileSelect]
);
const handleDrop = useCallback(

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
@ -8,14 +9,19 @@ import {
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { FileUpload } from "@/features/project/FileUpload.tsx";
import { EngineSelect } from "@/components/EngineSelect";
import { Upload } from "lucide-react";
import type { EngineResponse } from "@/api/generated/api.schemas";
interface UploadBinaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUpload: (file: File) => void;
onUpload: (file: File, engine?: string) => void;
isUploading: boolean;
uploadError: string | null;
onReset: () => void;
engines: EngineResponse[];
enginesLoading: boolean;
}
export const UploadBinaryDialog = ({
@ -25,7 +31,31 @@ export const UploadBinaryDialog = ({
isUploading,
uploadError,
onReset,
engines,
enginesLoading,
}: UploadBinaryDialogProps) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedEngine, setSelectedEngine] = useState<string>();
const handleFileSelect = (file: File) => {
setSelectedFile(file);
};
const handleEngineChange = (engine: string) => {
setSelectedEngine(engine || undefined);
};
const handleUpload = () => {
if (!selectedFile) return;
onUpload(selectedFile, selectedEngine);
};
const handleReset = () => {
setSelectedFile(null);
setSelectedEngine(undefined);
onReset();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
@ -45,19 +75,52 @@ export const UploadBinaryDialog = ({
<span className="text-sm font-medium">Select Binary File</span>
</div>
<FileUpload
onUpload={onUpload}
onUpload={() => {}}
onFileSelect={handleFileSelect}
isUploading={isUploading}
error={uploadError}
onReset={onReset}
onReset={handleReset}
accept=".exe,.com,.dll,.bin,.elf"
maxSize={100 * 1024 * 1024}
autoUpload={false}
/>
</div>
{selectedFile && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="bg-muted text-muted-foreground flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium">
2
</div>
<span className="text-sm font-medium">Analysis Engine</span>
<span className="text-muted-foreground text-xs">(optional)</span>
</div>
<EngineSelect
engines={engines}
value={selectedEngine}
onChange={handleEngineChange}
isLoading={enginesLoading}
placeholder="Select engine (optional)"
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isUploading}>
Cancel
</Button>
{selectedFile && (
<Button onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>Uploading...</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload
</>
)}
</Button>
)}
</DialogFooter>
</div>
</DialogContent>

View File

@ -4,6 +4,7 @@ import { useGetProject } from "@/api/generated/project/project.ts";
import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts";
import { useDeleteBinary, useGetBinaries, useUploadBinary } from "@/api/generated/binary/binary.ts";
import { downloadBinary } from "@/api/generated/binary/binary.ts";
import { useListEngines } from "@/api/generated/engine/engine.ts";
import { useQueryClient } from "@tanstack/react-query";
import type { BinaryResponse } from "@/api/generated/api.schemas.ts";
@ -28,6 +29,8 @@ export function useProjectPage(projectId: string) {
{ query: { enabled: !!projectId } }
);
const { data: engines, isLoading: enginesLoading } = useListEngines();
const { mutate: mutateDeleteBinary } = useDeleteBinary({
mutation: {
onSuccess: () => {
@ -69,8 +72,8 @@ export function useProjectPage(projectId: string) {
setBinaryToDelete(null);
};
const handleUploadBinary = (file: File) => {
uploadMutation.mutate({ projectId, data: { file } });
const handleUploadBinary = (file: File, engine?: string) => {
uploadMutation.mutate({ projectId, data: { file }, params: engine ? { engine } : undefined });
};
const handleDownloadBinary = async (binary: BinaryResponse) => {
@ -104,5 +107,7 @@ export function useProjectPage(projectId: string) {
uploadError,
handleUploadBinary,
handleDownloadBinary,
engines: engines ?? [],
enginesLoading,
};
}

View File

@ -1,7 +1,6 @@
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/EmptyState";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { Breadcrumbs } from "@/components/Breadcrumbs.tsx";
import { BinaryHeader } from "@/features/binary/BinaryHeader.tsx";
import { FunctionTree } from "@/features/binary/FunctionTree";
@ -14,6 +13,7 @@ import {
import { useBinaryPage } from "@/features/binary/hooks/useBinaryPage";
import { CodeViewer } from "@/features/binary/CodeViewer";
import { AIChatPanel } from "@/features/binary/AIChatPanel";
import { AnalyzeDialog } from "@/features/binary/AnalyzeDialog";
const Binary = () => {
const {
@ -39,11 +39,12 @@ const Binary = () => {
selectedFunction,
selectedFunctionLoading,
analyzeMutation,
confirmReanalyzeOpen,
setConfirmReanalyzeOpen,
handleAnalyze,
handleReanalyzeRequest,
handleReanalyzeConfirm,
analyzeDialogOpen,
setAnalyzeDialogOpen,
handleOpenAnalyzeDialog,
handleAnalyzeConfirm,
engines,
enginesLoading,
} = useBinaryPage();
const navigateToEntryPoint = () => {
@ -91,7 +92,7 @@ const Binary = () => {
binary={binary}
hasAnalysis={binary.staticAnalysisDone}
isAnalyzing={analyzeMutation.isPending}
onAnalyze={binary.staticAnalysisDone ? handleReanalyzeRequest : handleAnalyze}
onAnalyze={handleOpenAnalyzeDialog}
entryPoint={analysis?.entryPoint}
onNavigateToEntryPoint={navigateToEntryPoint}
/>
@ -165,14 +166,14 @@ const Binary = () => {
</ResizablePanelGroup>
</div>
<ConfirmDialog
open={confirmReanalyzeOpen}
onOpenChange={setConfirmReanalyzeOpen}
title="Re-analyze binary"
description="This will replace all current analysis data. Function names, comments, and decompiled code will be lost. Are you sure?"
confirmLabel="Re-analyze"
variant="destructive"
onConfirm={handleReanalyzeConfirm}
<AnalyzeDialog
key={analyzeDialogOpen ? "analyze-open" : "analyze-closed"}
open={analyzeDialogOpen}
onOpenChange={setAnalyzeDialogOpen}
isReanalyze={!!binary.staticAnalysisDone}
engines={engines}
enginesLoading={enginesLoading}
onConfirm={handleAnalyzeConfirm}
/>
</div>
);

View File

@ -32,6 +32,8 @@ const Project = () => {
uploadError,
handleUploadBinary,
handleDownloadBinary,
engines,
enginesLoading,
} = useProjectPage(projectId!);
if (!project || !workspace) {
@ -111,12 +113,15 @@ const Project = () => {
)}
<UploadBinaryDialog
key={uploadDialogOpen ? "upload-open" : "upload-closed"}
open={uploadDialogOpen}
onOpenChange={handleCloseUploadDialog}
onUpload={handleUploadBinary}
isUploading={uploadMutation.isPending}
uploadError={uploadError}
onReset={uploadMutation.reset}
engines={engines}
enginesLoading={enginesLoading}
/>
<ConfirmDialog