diff options
| author | Mason Reed <mason@vector35.com> | 2025-08-26 23:59:38 -0400 |
|---|---|---|
| committer | Mason Reed <mason@vector35.com> | 2025-10-01 21:38:39 -0400 |
| commit | ede39aee7e00c40a43b67ca18dd8ab80ee863d85 (patch) | |
| tree | 67c5eda347ece2282e3c888f38066b496e06dec8 | |
| parent | a1c46813e7f279aa4cfdb9dbb91c45b559ebeacd (diff) | |
[WARP] Enhanced network support
42 files changed, 3371 insertions, 256 deletions
@@ -322,6 +322,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] name = "cc" version = "1.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -450,6 +459,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] name = "console" version = "0.15.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -639,6 +663,27 @@ dependencies = [ ] [[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] name = "dispatch2" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1440,6 +1485,16 @@ dependencies = [ ] [[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.1", + "libc", +] + +[[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1831,6 +1886,12 @@ dependencies = [ ] [[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] name = "parking_lot" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2098,6 +2159,17 @@ dependencies = [ ] [[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + +[[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3021,6 +3093,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.3", "js-sys", + "serde", "sha1_smol", "wasm-bindgen", ] @@ -3076,8 +3149,10 @@ dependencies = [ "arboard", "binaryninja", "binaryninjacore-sys", + "compact_str", "criterion", "dashmap", + "directories", "insta", "itertools 0.14.0", "log", diff --git a/plugins/warp/Cargo.toml b/plugins/warp/Cargo.toml index 761ac961..702c500a 100644 --- a/plugins/warp/Cargo.toml +++ b/plugins/warp/Cargo.toml @@ -19,12 +19,14 @@ arboard = "3.4" walkdir = "2.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -uuid = { version = "1.12.0", features = ["v4"] } +uuid = { version = "1.12.0", features = ["v4", "serde"] } thiserror = "2.0" ar = { git = "https://github.com/mdsteele/rust-ar" } tempdir = "0.3.7" regex = "1.11" reqwest = { version = "0.12", features = ["blocking", "json", "multipart"] } +directories = "6.0" +compact_str = { version = "0.9.0", features = ["serde"] } # For reports minijinja = "2.10.2" diff --git a/plugins/warp/api/python/warp.py b/plugins/warp/api/python/warp.py index 66fef872..29537895 100644 --- a/plugins/warp/api/python/warp.py +++ b/plugins/warp/api/python/warp.py @@ -8,6 +8,7 @@ from binaryninja import BinaryView, Function, BasicBlock, Architecture, Platform from binaryninja._binaryninjacore import BNFreeString, BNAllocString, BNType from . import _warpcore as warpcore +from .warp_enums import WARPContainerSearchItemKind class WarpUUID: @@ -195,6 +196,108 @@ class WarpFunction: warpcore.BNWARPFunctionApply(self.handle, function.handle) +class WarpContainerSearchQuery: + def __init__(self, query: str, offset: Optional[int] = None, limit: Optional[int] = None, source: Optional[Source] = None, source_tags: Optional[List[str]] = None): + self.query = query + self.source = source + self.offset = offset + self.limit = limit + offset_ptr = None + if offset is not None: + self._c_offset = ctypes.c_size_t(offset) + offset_ptr = ctypes.byref(self._c_offset) + limit_ptr = None + if limit is not None: + self._c_limit = ctypes.c_size_t(limit) + limit_ptr = ctypes.byref(self._c_limit) + source_ptr = None + if source is not None: + self._c_source = source.uuid + source_ptr = ctypes.byref(self._c_source) + source_tags_len = 0 + source_tags_array_ptr = None + if source_tags is not None: + source_tags_ptr = (ctypes.c_char_p * len(source_tags))() + source_tags_len = len(source_tags) + for i in range(len(source_tags)): + source_tags_ptr[i] = source_tags[i].encode('utf-8') + source_tags_array_ptr = ctypes.cast(source_tags_ptr, ctypes.POINTER(ctypes.c_char_p)) + self.handle = warpcore.BNWARPNewContainerSearchQuery(query, offset_ptr, limit_ptr, source_ptr, source_tags_array_ptr, source_tags_len) + + def __del__(self): + if self.handle is not None: + warpcore.BNWARPFreeContainerSearchQueryReference(self.handle) + + def __repr__(self): + # TODO: Display offset and limit in a pythonic way. + if self.source is None: + return f"<WarpContainerSearchQuery '{self.query}'>" + return f"<WarpContainerSearchQuery '{self.query}': '{self.source}'>" + + +class WarpContainerSearchItem: + def __init__(self, handle: warpcore.BNWARPContainerSearchItem): + self.handle = handle + + def __del__(self): + if self.handle is not None: + warpcore.BNWARPFreeContainerSearchItemReference(self.handle) + + @property + def kind(self) -> WARPContainerSearchItemKind: + return WARPContainerSearchItemKind(warpcore.BNWARPContainerSearchItemGetKind(self.handle)) + + @property + def source(self) -> Source: + return Source(warpcore.BNWARPContainerSearchItemGetSource(self.handle)) + + @property + def name(self) -> str: + return warpcore.BNWARPContainerSearchItemGetName(self.handle) + + def get_type(self, arch: Architecture) -> Optional[Type]: + ty = warpcore.BNWARPContainerSearchItemGetType(arch.handle, self.handle) + if not ty: + return None + return Type(ty) + + @property + def function(self) -> Optional[WarpFunction]: + func = warpcore.BNWARPContainerSearchItemGetFunction(self.handle) + if not func: + return None + return WarpFunction(func) + + def __repr__(self): + return f"<WarpContainerSearchItem '{self.name}': '{self.source}'>" + + +class WarpContainerResponse: + def __init__(self, items: List[WarpContainerSearchItem], offset: int, total: int): + self.items = items + self.offset = offset + self.total = total + + def __iter__(self): + return iter(self.items) + + def __len__(self): + return len(self.items) + + def __repr__(self): + return f"<WarpContainerResponse items={len(self.items)} offset={self.offset} total={self.total}>" + + @staticmethod + def from_api(response: warpcore.BNWARPContainerSearchResponse) -> 'WarpContainerResponse': + try: + items = [] + for i in range(response.count): + items.append(WarpContainerSearchItem(warpcore.BNWARPNewContainerSearchItemReference(response.items[i]))) + return WarpContainerResponse(items=items, offset=response.offset, total=response.total) + finally: + warpcore.BNWARPFreeContainerSearchResponse(response) + + class _WarpContainerMetaclass(type): def __iter__(self): binaryninja._init_plugins() @@ -305,12 +408,19 @@ class WarpContainer(metaclass=_WarpContainerMetaclass): core_guids[i] = guids[i].uuid return warpcore.BNWARPContainerRemoveTypes(self.handle, source.uuid, core_guids, count) - def fetch_functions(self, target: WarpTarget, guids: List[FunctionGUID]): + def fetch_functions(self, target: WarpTarget, guids: List[FunctionGUID], source_tags: Optional[List[str]] = None): count = len(guids) core_guids = (warpcore.BNWARPFunctionGUID * count)() for i in range(count): core_guids[i] = guids[i].uuid - warpcore.BNWARPContainerFetchFunctions(self.handle, target.handle, core_guids, count) + if source_tags is None: + source_tags = [] + source_tags_ptr = (ctypes.c_char_p * len(source_tags))() + source_tags_len = len(source_tags) + for i in range(len(source_tags)): + source_tags_ptr[i] = source_tags[i].encode('utf-8') + source_tags_array_ptr = ctypes.cast(source_tags_ptr, ctypes.POINTER(ctypes.c_char_p)) + warpcore.BNWARPContainerFetchFunctions(self.handle, target.handle, source_tags_array_ptr, source_tags_len, core_guids, count) def get_sources_with_function_guid(self, target: WarpTarget, guid: FunctionGUID) -> List[Source]: count = ctypes.c_size_t() @@ -346,7 +456,7 @@ class WarpContainer(metaclass=_WarpContainerMetaclass): return result def get_type_with_guid(self, arch: Architecture, source: Source, guid: TypeGUID) -> Optional[Type]: - ty = warpcore.BNWARPContainerGetTypeWithGUID(self.handle, arch.handle, source.uuid, guid.uuid) + ty = warpcore.BNWARPContainerGetTypeWithGUID(arch.handle, self.handle, source.uuid, guid.uuid) if not ty: return None return Type(ty) @@ -362,6 +472,12 @@ class WarpContainer(metaclass=_WarpContainerMetaclass): warpcore.BNWARPFreeUUIDList(guids, count.value) return result + def search(self, query: WarpContainerSearchQuery) -> Optional[WarpContainerResponse]: + response = warpcore.BNWARPContainerSearch(self.handle, query.handle) + if not response: + return None + return WarpContainerResponse.from_api(response.contents) + def run_matcher(view: BinaryView): warpcore.BNWARPRunMatcher(view.handle) diff --git a/plugins/warp/api/python/warp_enums.py b/plugins/warp/api/python/warp_enums.py index e47ff1c3..86be3a6c 100644 --- a/plugins/warp/api/python/warp_enums.py +++ b/plugins/warp/api/python/warp_enums.py @@ -1 +1,8 @@ import enum + + +class WARPContainerSearchItemKind(enum.IntEnum): + WARPContainerSearchItemKindSource = 0 + WARPContainerSearchItemKindFunction = 1 + WARPContainerSearchItemKindType = 2 + WARPContainerSearchItemKindSymbol = 3 diff --git a/plugins/warp/api/warp.cpp b/plugins/warp/api/warp.cpp index 52c7ea9d..f6106605 100644 --- a/plugins/warp/api/warp.cpp +++ b/plugins/warp/api/warp.cpp @@ -5,6 +5,14 @@ using namespace Warp; +std::optional<WarpUUID> WarpUUID::FromString(const std::string &str) +{ + BNWARPUUID uuid = {}; + if (!BNWARPUUIDFromString(str.c_str(), &uuid)) + return std::nullopt; + return WarpUUID(uuid); +} + std::string WarpUUID::ToString() const { char *str = BNWARPUUIDGetString(&uuid); @@ -133,6 +141,94 @@ void Function::RemoveMatch(const BinaryNinja::Function &function) BNWARPFunctionApply(nullptr, function.m_object); } +ContainerSearchQuery::ContainerSearchQuery(BNWARPContainerSearchQuery *query) +{ + m_object = query; +} + +ContainerSearchQuery::ContainerSearchQuery(const std::string &query) +{ + m_object = BNWARPNewContainerSearchQuery(query.c_str(), nullptr, nullptr, nullptr, nullptr, 0); +} + +ContainerSearchQuery::ContainerSearchQuery(const std::string &query, const Source &source) +{ + m_object = BNWARPNewContainerSearchQuery(query.c_str(), nullptr, nullptr, source.Raw(), nullptr, 0); +} + +ContainerSearchQuery::ContainerSearchQuery(const std::string &query, size_t offset, size_t limit, const std::optional<Source> &source, const std::vector<SourceTag> &tags) +{ + size_t tagCount = tags.size(); + const char** rawTags = new const char*[tagCount]; + for (size_t i = 0; i < tagCount; i++) + rawTags[i] = tags[i].c_str(); + if (source) + m_object = BNWARPNewContainerSearchQuery(query.c_str(), &offset, &limit, source.value().Raw(), rawTags, tagCount); + else + m_object = BNWARPNewContainerSearchQuery(query.c_str(), &offset, &limit, nullptr, rawTags, tagCount); + delete[] rawTags; +} + +ContainerSearchItem::ContainerSearchItem(BNWARPContainerSearchItem *item) +{ + m_object = item; +} + +BNWARPContainerSearchItemKind ContainerSearchItem::GetKind() const +{ + return BNWARPContainerSearchItemGetKind(m_object); +} + +Source ContainerSearchItem::GetSource() const +{ + return BNWARPContainerSearchItemGetSource(m_object); +} + +BinaryNinja::Ref<BinaryNinja::Type> ContainerSearchItem::GetType(const BinaryNinja::Ref<BinaryNinja::Architecture> &arch) const +{ + BNType *type = BNWARPContainerSearchItemGetType(arch ? arch->m_object : nullptr, m_object); + if (!type) + return nullptr; + return new BinaryNinja::Type(type); +} + +std::string ContainerSearchItem::GetName() const +{ + // NOTE: In the future we may want the name to be optional, see rust core side for more info. + char *rawName = BNWARPContainerSearchItemGetName(m_object); + std::string name = rawName; + BNFreeString(rawName); + return name; +} + +Ref<Function> ContainerSearchItem::GetFunction() const +{ + BNWARPFunction *function = BNWARPContainerSearchItemGetFunction(m_object); + if (!function) + return nullptr; + return new Function(function); +} + + +ContainerSearchResponse::ContainerSearchResponse(std::vector<Ref<ContainerSearchItem>>&& items, size_t offset, + size_t total) +{ + this->items = std::move(items); + this->offset = offset; + this->total = total; +} + +ContainerSearchResponse ContainerSearchResponse::FromAPIObject(BNWARPContainerSearchResponse *response) +{ + std::vector<Ref<ContainerSearchItem>> items; + items.reserve(response->count); + for (int i = 0; i < response->count; i++) + items.push_back(new ContainerSearchItem(BNWARPNewContainerSearchItemReference(response->items[i]))); + ContainerSearchResponse resp = {std::move(items), response->offset, response->total}; + BNWARPFreeContainerSearchResponse(response); + return resp; +} + Container::Container(BNWARPContainer *container) { m_object = container; @@ -249,14 +345,19 @@ bool Container::RemoveTypes(const Source &source, const std::vector<TypeGUID> &g return result; } -void Container::FetchFunctions(const Target &target, const std::vector<FunctionGUID> &guids) const +void Container::FetchFunctions(const Target &target, const std::vector<FunctionGUID> &guids, const std::vector<SourceTag> &tags) const { size_t count = guids.size(); BNWARPFunctionGUID *apiGuids = new BNWARPFunctionGUID[count]; for (size_t i = 0; i < count; i++) apiGuids[i] = *guids[i].Raw(); - BNWARPContainerFetchFunctions(m_object, target.m_object, apiGuids, count); + size_t tagCount = tags.size(); + const char** rawTags = new const char*[tagCount]; + for (size_t i = 0; i < tagCount; i++) + rawTags[i] = tags[i].c_str(); + BNWARPContainerFetchFunctions(m_object, target.m_object, rawTags, tagCount, apiGuids, count); delete[] apiGuids; + delete[] rawTags; } std::vector<Source> Container::GetSourcesWithFunctionGUID(const Target& target, const FunctionGUID &guid) const @@ -314,6 +415,14 @@ std::vector<TypeGUID> Container::GetTypeGUIDsWithName(const Source &source, cons return result; } +std::optional<ContainerSearchResponse> Container::Search(const ContainerSearchQuery &query) const +{ + BNWARPContainerSearchResponse *response = BNWARPContainerSearch(m_object, query.m_object); + if (!response) + return std::nullopt; + return ContainerSearchResponse::FromAPIObject(response); +} + void Warp::RunMatcher(const BinaryNinja::BinaryView &view) { BNWARPRunMatcher(view.m_object); diff --git a/plugins/warp/api/warp.h b/plugins/warp/api/warp.h index 807ba998..14be9a0d 100644 --- a/plugins/warp/api/warp.h +++ b/plugins/warp/api/warp.h @@ -68,7 +68,7 @@ namespace Warp { { T *m_obj; #ifdef BN_REF_COUNT_DEBUG - void* m_assignmentTrace = nullptr; + void *m_assignmentTrace = nullptr; #endif public: @@ -230,8 +230,12 @@ namespace Warp { public: WarpUUID() = default; - - WarpUUID(BNWARPUUID uuid) : uuid(uuid) {} + + WarpUUID(BNWARPUUID uuid) : uuid(uuid) + { + } + + static std::optional<WarpUUID> FromString(const std::string &str); std::string ToString() const; @@ -245,12 +249,12 @@ namespace Warp { return !(*this == other); } - BNWARPUUID* RawMut() + BNWARPUUID *RawMut() { return &uuid; } - const BNWARPUUID* Raw() const + const BNWARPUUID *Raw() const { return &uuid; } @@ -261,6 +265,7 @@ namespace Warp { typedef WarpUUID FunctionGUID; typedef WarpUUID ConstraintGUID; typedef WarpUUID TypeGUID; + typedef std::string SourceTag; class Target : public WarpRefCountObject<BNWARPTarget, BNWARPNewTargetReference, BNWARPFreeTargetReference> @@ -268,9 +273,9 @@ namespace Warp { public: explicit Target(BNWARPTarget *target); - static Ref<Target> FromPlatform(const BinaryNinja::Platform& platform); + static Ref<Target> FromPlatform(const BinaryNinja::Platform &platform); }; - + struct Constraint { ConstraintGUID guid; @@ -278,7 +283,7 @@ namespace Warp { Constraint(ConstraintGUID guid, std::optional<int64_t> offset); - static Constraint FromAPIObject(BNWARPConstraint* constraint); + static Constraint FromAPIObject(BNWARPConstraint *constraint); }; struct FunctionComment @@ -288,7 +293,7 @@ namespace Warp { FunctionComment(std::string text, int64_t offset); - static FunctionComment FromAPIObject(BNWARPFunctionComment* comment); + static FunctionComment FromAPIObject(BNWARPFunctionComment *comment); }; class Function : public WarpRefCountObject<BNWARPFunction, BNWARPNewFunctionReference, BNWARPFreeFunctionReference> @@ -322,6 +327,51 @@ namespace Warp { static void RemoveMatch(const BinaryNinja::Function &function); }; + class ContainerSearchQuery : public WarpRefCountObject<BNWARPContainerSearchQuery, + BNWARPNewContainerSearchQueryReference, + BNWARPFreeContainerSearchQueryReference> + { + public: + explicit ContainerSearchQuery(BNWARPContainerSearchQuery *query); + + explicit ContainerSearchQuery(const std::string &query); + + ContainerSearchQuery(const std::string &query, const Source &source); + + ContainerSearchQuery(const std::string &query, size_t offset, size_t limit, + const std::optional<Source> &source = std::nullopt, + const std::vector<SourceTag> &tags = {}); + }; + + class ContainerSearchItem : public WarpRefCountObject<BNWARPContainerSearchItem, + BNWARPNewContainerSearchItemReference, + BNWARPFreeContainerSearchItemReference> + { + public: + explicit ContainerSearchItem(BNWARPContainerSearchItem *item); + + BNWARPContainerSearchItemKind GetKind() const; + + Source GetSource() const; + + BinaryNinja::Ref<BinaryNinja::Type> GetType(const BinaryNinja::Ref<BinaryNinja::Architecture> &arch) const; + + std::string GetName() const; + + Ref<Function> GetFunction() const; + }; + + struct ContainerSearchResponse + { + std::vector<Ref<ContainerSearchItem> > items; + size_t offset; + size_t total; + + ContainerSearchResponse(std::vector<Ref<ContainerSearchItem> > &&items, size_t offset, size_t total); + + static ContainerSearchResponse FromAPIObject(BNWARPContainerSearchResponse *response); + }; + class Container : public WarpRefCountObject<BNWARPContainer, BNWARPNewContainerReference, BNWARPFreeContainerReference> { @@ -345,36 +395,50 @@ namespace Warp { std::optional<std::string> SourcePath(const Source &source) const; - bool AddFunctions(const Target &target, const Source &source, const std::vector<Ref<Function> > &functions) const; + bool AddFunctions(const Target &target, const Source &source, + const std::vector<Ref<Function> > &functions) const; bool AddTypes(const BinaryNinja::BinaryView &view, const Source &source, const std::vector<BinaryNinja::Ref<BinaryNinja::Type> > &types) const; - bool RemoveFunctions(const Target &target, const Source &source, const std::vector<Ref<Function> > &functions) const; + bool RemoveFunctions(const Target &target, const Source &source, + const std::vector<Ref<Function> > &functions) const; bool RemoveTypes(const Source &source, const std::vector<TypeGUID> &guids) const; - void FetchFunctions(const Target& target, const std::vector<FunctionGUID> &guids) const; + void FetchFunctions(const Target &target, const std::vector<FunctionGUID> &guids, const std::vector<SourceTag> &tags = {}) const; - std::vector<Source> GetSourcesWithFunctionGUID(const Target& target, const FunctionGUID &guid) const; + std::vector<Source> GetSourcesWithFunctionGUID(const Target &target, const FunctionGUID &guid) const; std::vector<Source> GetSourcesWithTypeGUID(const TypeGUID &guid) const; - std::vector<Ref<Function> > GetFunctionsWithGUID(const Target& target, const Source &source, const FunctionGUID &guid) const; + std::vector<Ref<Function> > GetFunctionsWithGUID(const Target &target, const Source &source, + const FunctionGUID &guid) const; BinaryNinja::Ref<BinaryNinja::Type> GetTypeWithGUID(const BinaryNinja::Architecture &arch, const Source &source, const TypeGUID &guid) const; std::vector<TypeGUID> GetTypeGUIDsWithName(const Source &source, const std::string &name) const; + + std::optional<ContainerSearchResponse> Search(const ContainerSearchQuery &query) const; }; - void RunMatcher(const BinaryNinja::BinaryView& view); + void RunMatcher(const BinaryNinja::BinaryView &view); bool IsInstructionVariant(const BinaryNinja::LowLevelILFunction &function, BinaryNinja::ExprId idx); bool IsInstructionBlacklisted(const BinaryNinja::LowLevelILFunction &function, BinaryNinja::ExprId idx); - + std::optional<FunctionGUID> GetAnalysisFunctionGUID(const BinaryNinja::Function &function); std::optional<BasicBlockGUID> GetBasicBlockGUID(const BinaryNinja::BasicBlock &basicBlock); } + +template<> struct std::hash<Warp::WarpUUID> +{ + size_t operator()(Warp::WarpUUID const& item) const noexcept + { + // TODO: Use the raw bytes instead. + return std::hash<std::string>()(item.ToString()); + } +};
\ No newline at end of file diff --git a/plugins/warp/api/warpcore.h b/plugins/warp/api/warpcore.h index 20a59b7b..b62f2427 100644 --- a/plugins/warp/api/warpcore.h +++ b/plugins/warp/api/warpcore.h @@ -61,6 +61,7 @@ extern "C" }; char* BNWARPUUIDGetString(const BNWARPUUID* uuid); + bool BNWARPUUIDFromString(const char* str, BNWARPUUID* uuid); bool BNWARPUUIDEqual(const BNWARPUUID* a, const BNWARPUUID* b); void BNWARPFreeUUIDList(BNWARPUUID* uuids, size_t count); @@ -74,6 +75,24 @@ extern "C" typedef struct BNWARPContainer BNWARPContainer; typedef struct BNWARPFunction BNWARPFunction; typedef struct BNWARPConstraint BNWARPConstraint; + typedef struct BNWARPContainerSearchQuery BNWARPContainerSearchQuery; + typedef struct BNWARPContainerSearchItem BNWARPContainerSearchItem; + + enum BNWARPContainerSearchItemKind + { + WARPContainerSearchItemKindSource = 0, + WARPContainerSearchItemKindFunction = 1, + WARPContainerSearchItemKindType = 2, + WARPContainerSearchItemKindSymbol = 3, + }; + + struct BNWARPContainerSearchResponse + { + size_t count; + BNWARPContainerSearchItem** items; + size_t offset; + size_t total; + }; struct BNWARPConstraint { @@ -108,7 +127,7 @@ extern "C" WARP_FFI_API bool BNWARPContainerRemoveFunctions(BNWARPContainer* container, const BNWARPTarget* target, const BNWARPSource* source, BNWARPFunction** functions, size_t count); WARP_FFI_API bool BNWARPContainerRemoveTypes(BNWARPContainer* container, const BNWARPSource* source, BNWARPTypeGUID* types, size_t count); - WARP_FFI_API void BNWARPContainerFetchFunctions(BNWARPContainer* container, BNWARPTarget* target, const BNWARPTypeGUID* guids, size_t count); + WARP_FFI_API void BNWARPContainerFetchFunctions(BNWARPContainer* container, BNWARPTarget* target, const char** sourceTags, size_t sourceTagCount, const BNWARPTypeGUID* guids, size_t count); WARP_FFI_API BNWARPSource* BNWARPContainerGetSourcesWithFunctionGUID(BNWARPContainer* container, const BNWARPTarget* target, const BNWARPFunctionGUID* guid, size_t* count); WARP_FFI_API BNWARPSource* BNWARPContainerGetSourcesWithTypeGUID(BNWARPContainer* container, const BNWARPTypeGUID* guid, size_t* count); @@ -120,6 +139,25 @@ extern "C" WARP_FFI_API void BNWARPFreeContainerReference(BNWARPContainer* container); WARP_FFI_API void BNWARPFreeContainerList(BNWARPContainer** containers, size_t count); + WARP_FFI_API BNWARPContainerSearchQuery* BNWARPNewContainerSearchQuery(const char* query, const size_t* offset, const size_t* limit, const BNWARPSource* source, const char** sourceTags, size_t sourceTagCount); + + WARP_FFI_API BNWARPContainerSearchResponse* BNWARPContainerSearch(BNWARPContainer* container, const BNWARPContainerSearchQuery* query); + + WARP_FFI_API BNWARPContainerSearchItemKind BNWARPContainerSearchItemGetKind(BNWARPContainerSearchItem* item); + WARP_FFI_API BNWARPSource BNWARPContainerSearchItemGetSource(BNWARPContainerSearchItem* item); + WARP_FFI_API BNType* BNWARPContainerSearchItemGetType(BNArchitecture* arch, BNWARPContainerSearchItem* item); + WARP_FFI_API char* BNWARPContainerSearchItemGetName(BNWARPContainerSearchItem* item); + WARP_FFI_API BNWARPFunction* BNWARPContainerSearchItemGetFunction(BNWARPContainerSearchItem* item); + + WARP_FFI_API BNWARPContainerSearchQuery* BNWARPNewContainerSearchQueryReference(BNWARPContainerSearchQuery* query); + WARP_FFI_API void BNWARPFreeContainerSearchQueryReference(BNWARPContainerSearchQuery* query); + + WARP_FFI_API BNWARPContainerSearchItem* BNWARPNewContainerSearchItemReference(BNWARPContainerSearchItem* item); + WARP_FFI_API void BNWARPFreeContainerSearchItemReference(BNWARPContainerSearchItem* item); + WARP_FFI_API void BNWARPFreeContainerSearchItemList(BNWARPContainerSearchItem** items, size_t count); + + WARP_FFI_API void BNWARPFreeContainerSearchResponse(BNWARPContainerSearchResponse* response); + WARP_FFI_API void BNWARPFunctionApply(BNWARPFunction* function, BNFunction* analysisFunction); WARP_FFI_API BNWARPFunctionGUID BNWARPFunctionGetGUID(BNWARPFunction* function); WARP_FFI_API BNSymbol* BNWARPFunctionGetSymbol(BNWARPFunction* function, BNFunction* analysisFunction); diff --git a/plugins/warp/benches/function.rs b/plugins/warp/benches/function.rs index 526d97c4..299ed1eb 100644 --- a/plugins/warp/benches/function.rs +++ b/plugins/warp/benches/function.rs @@ -21,7 +21,7 @@ pub fn function_benchmark(c: &mut Criterion) { c.bench_function("signature all functions", |b| { b.iter(|| { for func in &functions { - let _ = build_function(&func, &func.lifted_il().unwrap(), false); + let _ = build_function(&func, || func.lifted_il().ok(), false); } }) }); @@ -32,8 +32,11 @@ pub fn function_benchmark(c: &mut Criterion) { functions .par_iter() .filter_map(|func| { - let llil = func.lifted_il().ok()?; - Some(build_function(func.as_ref(), llil.as_ref(), false)) + Some(build_function( + func.as_ref(), + || func.lifted_il().ok(), + false, + )) }) .collect::<Vec<_>>() }) diff --git a/plugins/warp/src/container.rs b/plugins/warp/src/container.rs index 4cabc60e..8c76bc8f 100644 --- a/plugins/warp/src/container.rs +++ b/plugins/warp/src/container.rs @@ -1,5 +1,5 @@ use crate::container::disk::NAMESPACE_DISK_SOURCE; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::{Debug, Display}; use std::hash::Hash; use std::io; @@ -10,6 +10,7 @@ use uuid::Uuid; use warp::r#type::guid::TypeGUID; use warp::r#type::{ComputedType, Type}; use warp::signature::function::{Function, FunctionGUID}; +use warp::symbol::Symbol; use warp::target::Target; pub mod disk; @@ -123,6 +124,77 @@ impl Display for SourcePath { } } +/// A tag associated with a source in a container. +/// +/// Tags can be used to categorize and filter sources when querying the container. +pub type SourceTag = compact_str::CompactString; + +/// A search query for finding items in a container. +/// +/// This struct represents a search request that can be used to find functions, types and any other +/// items associated with the container. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ContainerSearchQuery { + /// The search query string to match against items. + pub query: String, + /// Optional offset into the results for pagination. + pub offset: Option<usize>, + /// Optional maximum number of results to return. + pub limit: Option<usize>, + /// Optional source ID to restrict the search to. + pub source: Option<SourceId>, + /// Optional list of tags to restrict the search to. + pub tags: Vec<SourceTag>, + // TODO: Add field for function guid? conceivable someone wants to filter through those. +} + +impl ContainerSearchQuery { + pub fn new(query: String) -> Self { + Self { + query, + offset: None, + limit: None, + source: None, + tags: Vec::new(), + } + } +} + +/// An item returned from a container search. +/// +/// Contains the source ID where the item was found and the specific kind of item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerSearchItem { + /// The source ID where this item was found + pub source: SourceId, + /// The specific kind of item that was found + pub kind: ContainerSearchItemKind, +} + +/// The kind of item found in a container search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContainerSearchItemKind { + /// A source identified by its ID + Source { path: SourcePath, id: SourceId }, + /// A function definition + Function(Function), + /// A type definition + Type(Type), + /// A symbol definition + Symbol(Symbol), +} + +/// Response containing the results of a container search. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ContainerSearchResponse { + /// The matching items found in the search + pub items: Vec<ContainerSearchItem>, + /// Total number of matching items available + pub total: usize, + /// Starting offset of these results in the total set + pub offset: usize, +} + /// Storage for WARP information. /// /// Containers are made up of sources, see [`SourceId`] for more details. @@ -170,6 +242,9 @@ pub trait Container: Send + Sync + Display + Debug { /// that a source has uncommitted changes. fn is_source_uncommitted(&self, source: &SourceId) -> ContainerResult<bool>; + /// Retrieves the set of [`SourceTag`] for the given source. + fn source_tags(&self, source: &SourceId) -> ContainerResult<HashSet<SourceTag>>; + /// Retrieve the [`SourcePath`] for the given source. /// /// NOTE: This does not have to be a filesystem path, its representation is dictated @@ -216,6 +291,7 @@ pub trait Container: Send + Sync + Display + Debug { fn fetch_functions( &mut self, _target: &Target, + _tags: &[SourceTag], _functions: &[FunctionGUID], ) -> ContainerResult<()> { Ok(()) @@ -250,7 +326,6 @@ pub trait Container: Send + Sync + Display + Debug { guid: &FunctionGUID, ) -> ContainerResult<Vec<SourceId>>; - // TODO: Allocating with Vec is not good. /// Plural version of [`Container::sources_with_function_guid`]. /// /// Each source will have a list of the containing GUID's so that when looking up a source you give @@ -276,4 +351,15 @@ pub trait Container: Send + Sync + Display + Debug { ) -> ContainerResult<bool> { Ok(!self.functions_with_guid(target, source, guid)?.is_empty()) } + + /// Perform a paginated search over the container contents. + /// + /// The container implementation is responsible for interpreting [`ContainerSearchQuery::query`] + /// for example, locally you may not have the capabilities to perform a sane fuzzy search, so the query + /// is exact, whereas a database-backed container may opt to instead perform a fuzzy search. + /// + /// NOTE: This is intended for user-performed actions, as the query may look up over the network. + fn search(&self, _query: &ContainerSearchQuery) -> ContainerResult<ContainerSearchResponse> { + Ok(ContainerSearchResponse::default()) + } } diff --git a/plugins/warp/src/container/disk.rs b/plugins/warp/src/container/disk.rs index ca685a5f..02da0f30 100644 --- a/plugins/warp/src/container/disk.rs +++ b/plugins/warp/src/container/disk.rs @@ -1,5 +1,7 @@ -use crate::container::{Container, ContainerError, ContainerResult, SourceId, SourcePath}; -use std::collections::HashMap; +use crate::container::{ + Container, ContainerError, ContainerResult, SourceId, SourcePath, SourceTag, +}; +use std::collections::{HashMap, HashSet}; use std::fmt::{Debug, Display, Formatter}; use std::hash::{Hash, Hasher}; use std::path::PathBuf; @@ -20,11 +22,12 @@ pub const NAMESPACE_DISK_SOURCE: Uuid = uuid!("ea89e8ab-a27a-432b-8fbd-77b026cd5 pub struct DiskContainer { pub name: String, pub sources: HashMap<SourceId, DiskContainerSource>, + pub writable: bool, } impl DiskContainer { pub fn new(name: String, sources: HashMap<SourceId, DiskContainerSource>) -> Self { - Self { name, sources } + Self { name, sources, writable: true } } pub fn new_from_dir(dir_path: PathBuf) -> Self { @@ -56,6 +59,22 @@ impl DiskContainer { Self::new(name, sources) } + + pub fn insert_source(&mut self, id: SourceId, path: SourcePath) -> ContainerResult<()> { + if !self.writable || self.sources.contains_key(&id) { + return Err(ContainerError::SourceAlreadyExists(path)); + } + // NOTE: We let anyone add a file from anywhere on the file system because of this. + let disk_source = match path.0.exists() { + true => DiskContainerSource::new_from_path(path.clone())?, + false => { + let file = WarpFile::new(WarpFileHeader::new(), vec![]); + DiskContainerSource::new(path, file) + } + }; + self.sources.insert(id, disk_source); + Ok(()) + } } impl Container for DiskContainer { @@ -66,23 +85,8 @@ impl Container for DiskContainer { fn add_source(&mut self, path: SourcePath) -> ContainerResult<SourceId> { // Disk sources have there source id computed from the path. let source_id = path.to_source_id(); - if self.sources.contains_key(&source_id) { - return Err(ContainerError::SourceAlreadyExists(path)); - } - // NOTE: We let anyone add a file from anywhere on the file system because of this. - match path.0.exists() { - true => { - let disk_source = DiskContainerSource::new_from_path(path.clone())?; - self.sources.insert(source_id, disk_source); - Ok(source_id) - } - false => { - let file = WarpFile::new(WarpFileHeader::new(), vec![]); - let disk_source = DiskContainerSource::new(path, file); - self.sources.insert(source_id, disk_source); - Ok(source_id) - } - } + self.insert_source(source_id, path)?; + Ok(source_id) } fn commit_source(&mut self, source: &SourceId) -> ContainerResult<bool> { @@ -99,8 +103,7 @@ impl Container for DiskContainer { .sources .get(source) .ok_or(ContainerError::SourceNotFound(*source))?; - // TODO: I think this should be up to the container. (cant write to bundled files) - Ok(true) + Ok(self.writable) } fn is_source_uncommitted(&self, source: &SourceId) -> ContainerResult<bool> { @@ -111,6 +114,14 @@ impl Container for DiskContainer { Ok(disk_source.uncommitted) } + fn source_tags(&self, source: &SourceId) -> ContainerResult<HashSet<SourceTag>> { + let disk_source = self + .sources + .get(source) + .ok_or(ContainerError::SourceNotFound(*source))?; + Ok(disk_source.tags.clone()) + } + fn source_path(&self, source: &SourceId) -> ContainerResult<SourcePath> { let disk_source = self .sources @@ -277,6 +288,7 @@ impl Debug for DiskContainer { pub struct DiskContainerSource { pub path: SourcePath, + pub tags: HashSet<SourceTag>, file: WarpFile<'static>, uncommitted: bool, } @@ -285,6 +297,7 @@ impl DiskContainerSource { pub fn new(path: SourcePath, file: WarpFile<'static>) -> Self { Self { path, + tags: HashSet::new(), file, uncommitted: false, } diff --git a/plugins/warp/src/container/memory.rs b/plugins/warp/src/container/memory.rs index cf53390e..51628d6c 100644 --- a/plugins/warp/src/container/memory.rs +++ b/plugins/warp/src/container/memory.rs @@ -1,5 +1,7 @@ -use crate::container::{Container, ContainerError, ContainerResult, SourceId, SourcePath}; -use std::collections::HashMap; +use crate::container::{ + Container, ContainerError, ContainerResult, SourceId, SourcePath, SourceTag, +}; +use std::collections::{HashMap, HashSet}; use std::fmt::Display; use warp::r#type::guid::TypeGUID; use warp::r#type::{ComputedType, Type}; @@ -73,6 +75,15 @@ impl Container for MemoryContainer { Ok(false) } + fn source_tags(&self, source: &SourceId) -> ContainerResult<HashSet<SourceTag>> { + let _memory_source = self + .sources + .get(source) + .ok_or(ContainerError::SourceNotFound(*source))?; + // NOTE: Memory containers do not have a notion of tags. + Ok(HashSet::default()) + } + fn source_path(&self, source: &SourceId) -> ContainerResult<SourcePath> { Err(ContainerError::SourcePathUnavailable(*source)) } diff --git a/plugins/warp/src/container/network.rs b/plugins/warp/src/container/network.rs index 2a2d7c65..11f4db6b 100644 --- a/plugins/warp/src/container/network.rs +++ b/plugins/warp/src/container/network.rs @@ -1,7 +1,12 @@ use crate::container::disk::DiskContainer; -use crate::container::{Container, ContainerError, ContainerResult, SourceId, SourcePath}; -use std::collections::HashMap; +use crate::container::{ + Container, ContainerError, ContainerResult, ContainerSearchQuery, ContainerSearchResponse, + SourceId, SourcePath, SourceTag, +}; +use directories::ProjectDirs; +use std::collections::{HashMap, HashSet}; use std::fmt::{Debug, Display, Formatter}; +use std::path::PathBuf; use warp::chunk::{Chunk, ChunkKind, CompressionType}; use warp::r#type::guid::TypeGUID; use warp::r#type::{ComputedType, Type}; @@ -12,6 +17,7 @@ use warp::{WarpFile, WarpFileHeader}; pub mod client; +use crate::container::ContainerError::CannotCreateSource; pub use client::NetworkClient; /// This is the id on the server for the [`Target`], we can get it via [`NetworkClient::query_target_id`]. @@ -22,23 +28,42 @@ pub struct NetworkContainer { /// This is the store that the interface will write to; then we have special functions for pulling /// and pushing to the network source. cache: DiskContainer, + /// Where to place newly created sources. + /// + /// This is typically a directory inside [`NetworkContainer::root_cache_location`]. + cache_path: PathBuf, /// Populated when targets are queried. known_targets: HashMap<Target, Option<NetworkTargetId>>, - /// Populated with function sources are queried. + /// Populated when function sources are queried. known_function_sources: HashMap<FunctionGUID, Vec<SourceId>>, /// Populated when user adds function, this is used for writing back to the server. added_chunks: HashMap<SourceId, Vec<Chunk<'static>>>, + /// Populated when connecting to the server, this is used to determine which sources are writable. + /// + /// NOTE: This is only populated when logged in, as guest users do not have write permissions. + writable_sources: HashSet<SourceId>, } impl NetworkContainer { - pub fn new(client: NetworkClient) -> Self { - Self { - cache: DiskContainer::new("Network Container".to_string(), HashMap::new()), + pub fn new(client: NetworkClient, cache_path: PathBuf, writable_sources: &[SourceId]) -> Self { + let mut container = Self { + cache: DiskContainer::new_from_dir(cache_path.clone()), + cache_path, client, known_targets: HashMap::new(), known_function_sources: HashMap::new(), added_chunks: HashMap::new(), + writable_sources: writable_sources.into_iter().copied().collect(), + }; + + // TODO: Because of this little hack, methinks we should move writable sources to after the + // TODO: container is actually created, but before it is moved into the global container cache. + // Probe all writable sources, so the container knows about them properly. + for source in writable_sources { + container.probe_source(*source); } + + container } /// Gets the network id for the `target`, this will be used in later function queries. @@ -72,6 +97,7 @@ impl NetworkContainer { pub fn get_unseen_functions_source( &mut self, target: Option<&Target>, + tags: &[SourceTag], guids: &[FunctionGUID], ) -> HashMap<SourceId, Vec<FunctionGUID>> { let Some(target_id) = target.and_then(|t| self.get_target_id(t)) else { @@ -88,9 +114,9 @@ impl NetworkContainer { let mut result: HashMap<SourceId, Vec<FunctionGUID>> = HashMap::new(); // Only query server for unknown guids if we have any. if !unknown.is_empty() { - if let Some(queried_results) = self - .client - .query_functions_source(Some(target_id), &unknown) + if let Some(queried_results) = + self.client + .query_functions_source(Some(target_id), tags, &unknown) { // Cache the new results, this means we will not try and contact the server for that guids source. // NOTE: Here we do not just simply list the queried results because we also @@ -139,6 +165,8 @@ impl NetworkContainer { match &chunk.kind { ChunkKind::Signature(sc) => { let functions: Vec<_> = sc.functions().collect(); + // Probe the source before attempting to access it, as it might not exist locally. + self.probe_source(*source); match self.cache.add_functions(target, source, &functions) { Ok(_) => log::debug!( "Added {} functions into cached source '{}'", @@ -164,7 +192,46 @@ impl NetworkContainer { /// /// **This is blocking** pub fn push_file(&mut self, source_id: SourceId, file: &WarpFile) { - self.client.push_file(source_id, file); + // TODO: We need a better name for the commit. I would like to derive it automatically from + // TODO: something instead of having the user give it TBH. + self.client.push_file(source_id, file, "commit"); + } + + /// Probe the source to make sure it exists in the cache. Retrieving the name from the server. + /// + /// **This is blocking** + pub fn probe_source(&mut self, source_id: SourceId) { + if !self.cache.source_path(&source_id).is_ok() { + // Add the source to the cache. Using the source id and source name as the source path. + match self.client.source_name(source_id) { + Ok(source_name) => { + // To prevent two sources with the same name colliding, we add the source id to the source name. + let source_path = self + .cache_path + .join(source_id.to_string()) + .join(source_name); + let _ = self.cache.insert_source(source_id, SourcePath(source_path)); + } + Err(e) => { + log::error!("Failed to probe source '{}': {}", source_id, e); + } + } + } + } + + pub fn root_cache_location() -> PathBuf { + // - Windows: %LOCALAPPDATA%\<org>\<app>\cache + // - macOS: ~/Library/Caches/<org>.<app> + // - Linux: $XDG_CACHE_HOME/<app> or ~/.cache/<app> + if let Some(proj_dirs) = ProjectDirs::from("", "Vector35", "Binary Ninja") { + proj_dirs.cache_dir().to_path_buf() + } else { + // Fallback if OS dirs cannot be determined + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".cache") + .join("binaryninja") + } } } @@ -174,9 +241,16 @@ impl Container for NetworkContainer { } fn add_source(&mut self, path: SourcePath) -> ContainerResult<SourceId> { - // TODO: How do we want to let users create new sources? - log::error!("NetworkContainer::add_source not allowed"); - Err(ContainerError::CannotCreateSource(path)) + // Send a **blocking** request to the server to create the source. + // NOTE: The user must be logged in for this to work. + // TODO: Some better error handling to alert the user that they are not logged in / creating existing sources. + let source = self + .client + .create_source(&path.to_string()) + .map_err(|_| CannotCreateSource(path))?; + // Must probe the source before attempting to access it, as it does not exist locally. + self.probe_source(source); + Ok(source) } fn commit_source(&mut self, source: &SourceId) -> ContainerResult<bool> { @@ -184,21 +258,26 @@ impl Container for NetworkContainer { .added_chunks .remove(source) .ok_or(ContainerError::SourceNotFound(source.clone()))?; - let file = WarpFile::new(WarpFileHeader::new(), chunks); + // Because each add operation is its own chunk, we should merge them into larger chunks before sending. + let merged_chunks = Chunk::merge(&chunks, CompressionType::Zstd); + let file = WarpFile::new(WarpFileHeader::new(), merged_chunks); self.push_file(*source, &file); Ok(true) } fn is_source_writable(&self, source: &SourceId) -> ContainerResult<bool> { - // TODO: This is retrievable from /users/me/sources we will grab it when connecting. - log::error!("NetworkContainer::is_source_writable not allowed"); - Err(ContainerError::SourceNotWritable(source.clone())) + // Assume that all writable_sources are also in the cache (through `probe_source`). + Ok(self.writable_sources.contains(source)) } fn is_source_uncommitted(&self, source: &SourceId) -> ContainerResult<bool> { Ok(self.added_chunks.contains_key(source)) } + fn source_tags(&self, source: &SourceId) -> ContainerResult<HashSet<SourceTag>> { + self.cache.source_tags(source) + } + fn source_path(&self, source: &SourceId) -> ContainerResult<SourcePath> { self.cache.source_path(source) } @@ -246,10 +325,12 @@ impl Container for NetworkContainer { fn fetch_functions( &mut self, target: &Target, + tags: &[SourceTag], functions: &[FunctionGUID], ) -> ContainerResult<()> { // NOTE: Blocking request to get the mapped function sources. - let mapped_unseen_functions = self.get_unseen_functions_source(Some(&target), functions); + let mapped_unseen_functions = + self.get_unseen_functions_source(Some(&target), tags, functions); // Actually get the function data for the unseen guids, we really only want to do this once per // session, anymore, and this is annoying! @@ -308,16 +389,28 @@ impl Container for NetworkContainer { ) -> ContainerResult<Vec<Function>> { self.cache.functions_with_guid(target, source, guid) } + + fn search(&self, query: &ContainerSearchQuery) -> ContainerResult<ContainerSearchResponse> { + // TODO: Give this an actual network error. + self.client + .search(query) + .ok_or(ContainerError::CorruptedData( + "search query failed to validate", + )) + } } impl Debug for NetworkContainer { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NetworkContainer").finish() + f.debug_struct("NetworkContainer") + .field("client", &self.client) + .field("cache_path", &self.cache_path) + .finish() } } impl Display for NetworkContainer { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NetworkContainer").finish() + Display::fmt(&self.client.server_url, f) } } diff --git a/plugins/warp/src/container/network/client.rs b/plugins/warp/src/container/network/client.rs index f77f1118..39e7640a 100644 --- a/plugins/warp/src/container/network/client.rs +++ b/plugins/warp/src/container/network/client.rs @@ -1,12 +1,20 @@ use crate::container::network::NetworkTargetId; -use crate::container::SourceId; +use crate::container::{ + ContainerSearchItem, ContainerSearchItemKind, ContainerSearchQuery, ContainerSearchResponse, + SourceId, SourcePath, SourceTag, +}; use reqwest::blocking::Client; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use reqwest::StatusCode; +use serde::Deserialize; use serde_json::json; use std::collections::HashMap; use std::str::FromStr; -use warp::signature::function::FunctionGUID; +use uuid::Uuid; +use warp::chunk::ChunkKind; +use warp::r#type::guid::TypeGUID; +use warp::r#type::{ComputedType, Type}; +use warp::signature::function::{Function, FunctionGUID}; use warp::target::Target; use warp::WarpFile; @@ -16,7 +24,7 @@ use warp::WarpFile; #[derive(Clone, Debug)] pub struct NetworkClient { client: Client, - server_url: String, + pub server_url: String, } impl NetworkClient { @@ -65,6 +73,116 @@ impl NetworkClient { Ok(resp.status()) } + /// Query the logged in user. + /// + /// NOTE: **THIS IS BLOCKING** + /// + /// Route: `api/v1/users/me` (TODO: Comment about the query) + pub fn current_user(&self) -> reqwest::Result<(i32, String)> { + let current_user_url = format!("{}/api/v1/users/me", self.server_url); + + #[derive(Deserialize)] + struct CurrentUser { + username: String, + id: i32, + } + + let resp = self + .client + .get(¤t_user_url) + .send()? + .error_for_status()?; + let user: CurrentUser = resp.json()?; + Ok((user.id, user.username)) + } + + /// Query the logged in user. + /// + /// NOTE: **THIS IS BLOCKING** + /// + /// Route: `api/v1/users/me` (TODO: Comment about the query) + pub fn source_name(&self, id: SourceId) -> reqwest::Result<String> { + let source_url = format!("{}/api/v1/sources/{}", self.server_url, id); + + #[derive(Deserialize)] + struct Source { + name: String, + } + + let resp = self.client.get(&source_url).send()?.error_for_status()?; + let src: Source = resp.json()?; + Ok(src.name) + } + + /// Create a new source with the given name. + /// + /// The current user will be added to the source. + /// + /// NOTE: You must be logged in to create a source. + /// + /// NOTE: **THIS IS BLOCKING** + /// + /// Route: `api/v1/sources/` + pub fn create_source(&self, name: &str) -> reqwest::Result<SourceId> { + let source_url = format!("{}/api/v1/sources", self.server_url); + + let body = json!({ + "name": name, + // Passing nothing here will add the current user to the source. + "user_ids": [] + }); + + #[derive(Deserialize)] + struct CreateSourceResponse { + id: Uuid, + } + + let resp = self + .client + .post(&source_url) + .json(&body) + .send()? + .error_for_status()?; + + let parsed: CreateSourceResponse = resp.json()?; + Ok(SourceId(parsed.id)) + } + + /// Query the [`SourceId`]s for the given user. + /// + /// NOTE: **THIS IS BLOCKING** + /// + /// Route: `api/v1/sources/query` (TODO: Comment about the query) + pub fn query_sources(&self, user_id: Option<i32>) -> reqwest::Result<Vec<SourceId>> { + let sources_url = format!("{}/api/v1/sources/query", self.server_url); + + #[derive(Deserialize)] + struct SourceItem { + id: Uuid, + } + + #[derive(Deserialize)] + struct SourcesQueryResponse { + items: Vec<SourceItem>, + } + + let mut query = HashMap::new(); + if let Some(user_id) = user_id { + query.insert("user_id", user_id); + } + let query_str = json!(query).to_string(); + let resp = self + .client + .post(&sources_url) + .body(query_str) + .header("Content-Type", "application/json") + .send()? + .error_for_status()?; + + let parsed: SourcesQueryResponse = resp.json()?; + Ok(parsed.items.into_iter().map(|it| SourceId(it.id)).collect()) + } + /// Query the [`NetworkTargetId`] for the given [`Target`]. /// /// NOTE: **THIS IS BLOCKING** @@ -78,25 +196,36 @@ impl NetworkClient { query.insert("platform", platform); } if let Some(architecture) = &target.architecture { - query.insert("architecture", architecture); + query.insert("arch", architecture); + } + let query_str = json!(query).to_string(); + + #[derive(Deserialize)] + struct TargetQueryResponse { + id: NetworkTargetId, } // NOTE: This is blocking. - let target_id: NetworkTargetId = self + let response = self .client - .get(query_target_url) - .query(&query) + .post(query_target_url) + .body(query_str) + .header("Content-Type", "application/json") .send() - .ok()? - .json::<NetworkTargetId>() .ok()?; - Some(target_id) + // Assuming the first response is the one we want. + // TODO: Handle multiple responses, or error out. + let json_response: Vec<TargetQueryResponse> = response.json().ok()?; + let first_response = json_response.first()?; + + Some(first_response.id) } fn query_functions_body( target: Option<NetworkTargetId>, source: Option<SourceId>, + source_tags: &[SourceTag], guids: &[FunctionGUID], ) -> serde_json::Value { let guids_str: Vec<String> = guids.iter().map(|g| g.to_string()).collect(); @@ -112,6 +241,9 @@ impl NetworkClient { if let Some(source_id) = source { body["source_id"] = json!(source_id.to_string()); } + if !source_tags.is_empty() { + body["source_tags"] = json!(source_tags); + } body } @@ -127,7 +259,9 @@ impl NetworkClient { guids: &[FunctionGUID], ) -> Option<WarpFile<'static>> { let query_functions_url = format!("{}/api/v1/functions/query", self.server_url); - let payload = Self::query_functions_body(target, source, guids); + // TODO: Allow for source tags? We really only need this in query_functions_source as that + // TODO: is what prevents a undesired source from being "known" to the container. + let payload = Self::query_functions_body(target, source, &[], guids); // Make the POST request let response = self @@ -154,11 +288,12 @@ impl NetworkClient { pub fn query_functions_source( &self, target: Option<NetworkTargetId>, + tags: &[SourceTag], guids: &[FunctionGUID], ) -> Option<HashMap<SourceId, Vec<FunctionGUID>>> { let query_functions_source_url = format!("{}/api/v1/functions/query/source", self.server_url); - let payload = Self::query_functions_body(target, None, guids); + let payload = Self::query_functions_body(target, None, tags, guids); // Make the POST request let response = self @@ -194,20 +329,24 @@ impl NetworkClient { /// NOTE: **THIS IS BLOCKING** /// /// Route: `api/v1/files/{source}` - pub fn push_file(&self, source_id: SourceId, file: &WarpFile) -> bool { - let push_file_url = format!("{}/api/v1/files/{}", self.server_url, source_id.to_string()); + pub fn push_file(&self, source_id: SourceId, file: &WarpFile, name: &str) -> bool { + let push_file_url = format!("{}/api/v1/files", self.server_url); // Convert WarpFile to bytes let file_bytes = file.to_bytes(); - // Create the form part with the file - let form = reqwest::blocking::multipart::Form::new().part( - "file", - reqwest::blocking::multipart::Part::bytes(file_bytes) - .file_name("data.warp") - .mime_str("application/octet-stream") - .unwrap(), - ); + let Ok(file_part) = reqwest::blocking::multipart::Part::bytes(file_bytes) + .file_name("data.warp") + .mime_str("application/octet-stream") + else { + log::error!("Failed to create file part"); + return false; + }; + + let form = reqwest::blocking::multipart::Form::new() + .part("file", file_part) + .text("name", name.to_string()) + .text("source", source_id.to_string()); // Send the request match self.client.post(&push_file_url).multipart(form).send() { @@ -225,4 +364,226 @@ impl NetworkClient { } } } + + pub fn function_data(&self, id: i32) -> Option<Function> { + let function_data_url = format!("{}/api/v1/functions/{}/data", self.server_url, id); + let response = self.client.get(&function_data_url).send().ok()?; + if !response.status().is_success() { + log::error!( + "Failed to fetch function data for {}: {}", + id, + response.status() + ); + return None; + } + let bytes = response.bytes().ok()?; + Function::from_bytes(bytes.as_ref()) + } + + pub fn function_datas(&self, ids: &[i32]) -> Option<Vec<Function>> { + if ids.is_empty() { + return Some(Vec::new()); + } + let function_data_url = format!("{}/api/v1/functions/data", self.server_url); + let body = json!({ + "ids": ids, + }); + let response = self + .client + .post(&function_data_url) + .json(&body) + .send() + .ok()?; + if !response.status().is_success() { + log::error!("Failed to fetch function data: {}", response.status()); + return None; + } + let bytes = response.bytes().ok()?; + let file = WarpFile::from_bytes(bytes.as_ref())?; + let mut functions = Vec::with_capacity(ids.len()); + for chunk in file.chunks { + let ChunkKind::Signature(sc) = chunk.kind else { + continue; + }; + functions.extend(sc.functions()); + } + Some(functions) + } + + pub fn type_data(&self, guid: TypeGUID) -> Option<Type> { + let type_data_url = format!("{}/api/v1/types/{}/data", self.server_url, guid.to_string()); + let response = self.client.get(&type_data_url).send().ok()?; + if !response.status().is_success() { + log::error!( + "Failed to fetch type data for {}: {}", + guid.to_string(), + response.status() + ); + return None; + } + let bytes = response.bytes().ok()?; + Type::from_bytes(bytes.as_ref()) + } + + pub fn type_datas(&self, guids: &[TypeGUID]) -> Option<Vec<ComputedType>> { + if guids.is_empty() { + return Some(Vec::new()); + } + let type_data_url = format!("{}/api/v1/types/data", self.server_url); + let body = json!({ + "ids": guids.iter().map(|g| g.to_string()).collect::<Vec<_>>(), + }); + let response = self.client.post(&type_data_url).json(&body).send().ok()?; + if !response.status().is_success() { + log::error!("Failed to fetch type data: {}", response.status()); + return None; + } + let bytes = response.bytes().ok()?; + let file = WarpFile::from_bytes(bytes.as_ref())?; + let mut types = Vec::with_capacity(guids.len()); + for chunk in file.chunks { + let ChunkKind::Type(tc) = chunk.kind else { + continue; + }; + types.extend(tc.types()); + } + Some(types) + } + + pub fn search(&self, query: &ContainerSearchQuery) -> Option<ContainerSearchResponse> { + let search_url = format!("{}/api/v1/search", self.server_url); + + #[derive(serde::Serialize)] + struct SearchRequest<'a> { + #[serde(rename = "q")] + q: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] + offset: Option<usize>, + #[serde(rename = "source_id", skip_serializing_if = "Option::is_none")] + source_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + source_tags: Option<Vec<SourceTag>>, + #[serde(skip_serializing_if = "Option::is_none")] + retrieve_data: Option<bool>, + } + + #[derive(serde::Deserialize)] + struct SearchResponse { + items: Vec<SearchItem>, + offset: usize, + total: usize, + } + + #[derive(serde::Deserialize)] + struct SearchItem { + id: String, + kind: String, + #[serde(default)] + name: Option<String>, + #[serde(default)] + source_id: Option<Uuid>, + #[serde(default)] + data: Option<Vec<u8>>, + } + + let source_id_str = query.source.map(|s| s.to_string()); + let request = SearchRequest { + q: &query.query, + limit: query.limit, + offset: query.offset, + source_id: source_id_str, + source_tags: match query.tags.is_empty() { + true => None, + false => Some(query.tags.clone()), + }, + // This must be passed to retrieve the function and type data. + retrieve_data: Some(true), + }; + + let resp = match self.client.get(search_url).query(&request).send() { + Ok(r) => r, + Err(err) => { + log::error!("Failed to send search request: {}", err); + return None; + } + }; + + let Ok(parsed) = resp.json::<SearchResponse>() else { + log::error!("Failed to parse search response"); + return None; + }; + + // TODO: This is quite scuffed, but it works for now. (Mostly just that it looks bad and queries a lot) + // TODO: Here I think would be a good place to sort it so sources always come first. + // TODO: Users searching will want to get to the source first, likely to whitelist or blacklist. + let mut items = Vec::with_capacity(parsed.items.len()); + for item in parsed.items { + let Some(source_uuid) = item.source_id else { + // Currently not interested in items without a source id. + // Things like symbols do not have a source id. + continue; + }; + + let kind = match item.kind.as_str() { + "function" => { + let Some(data) = &item.data else { + log::warn!( + "Function item {} has no data from network, skipping...", + item.id + ); + continue; + }; + let Some(func) = Function::from_bytes(&data) else { + log::warn!( + "Function item {} has invalid data from network, skipping...", + item.id + ); + continue; + }; + ContainerSearchItemKind::Function(func) + } + "source" => ContainerSearchItemKind::Source { + path: match item.name { + None => { + log::warn!("Source item {} has no name", item.id); + continue; + } + Some(name) => SourcePath(format!("{}/{}", self.server_url, name).into()), + }, + id: SourceId(source_uuid), + }, + "type" => { + let Some(data) = &item.data else { + log::warn!( + "Type item {} has no data from network, skipping...", + item.id + ); + continue; + }; + let Some(ty) = Type::from_bytes(&data) else { + log::warn!( + "Type item {} has invalid data from network, skipping...", + item.id + ); + continue; + }; + ContainerSearchItemKind::Type(ty) + } + _ => continue, + }; + + items.push(ContainerSearchItem { + source: SourceId(source_uuid), + kind, + }); + } + + Some(ContainerSearchResponse { + items, + total: parsed.total, + offset: parsed.offset, + }) + } } diff --git a/plugins/warp/src/convert/types.rs b/plugins/warp/src/convert/types.rs index 1dfe5fa9..2401a039 100644 --- a/plugins/warp/src/convert/types.rs +++ b/plugins/warp/src/convert/types.rs @@ -279,9 +279,10 @@ pub fn to_bn_calling_convention<A: BNArchitecture>( arch.get_default_calling_convention().unwrap() } -pub fn to_bn_type<A: BNArchitecture>(arch: &A, ty: &Type) -> BNRef<BNType> { +// Always pass the architecture unless you know what you're doing! +pub fn to_bn_type<A: BNArchitecture + Copy>(arch: Option<A>, ty: &Type) -> BNRef<BNType> { let bits_to_bytes = |val: u64| (val / 8); - let addr_size = arch.address_size() as u64; + let addr_size = arch.map(|a| a.address_size()).unwrap_or(8) as u64; match &ty.class { TypeClass::Void => BNType::void(), TypeClass::Boolean(_) => BNType::bool(), @@ -438,8 +439,8 @@ pub fn to_bn_type<A: BNArchitecture>(arch: &A, ty: &Type) -> BNRef<BNType> { // TODO: Variable arguments let variable_args = false; // If we have a calling convention we run the extended function type creation. - match c.calling_convention.as_ref() { - Some(cc) => { + match (c.calling_convention.as_ref(), arch.as_ref()) { + (Some(cc), Some(arch)) => { let calling_convention = to_bn_calling_convention(arch, cc); BNType::function_with_opts( &return_type, @@ -449,7 +450,7 @@ pub fn to_bn_type<A: BNArchitecture>(arch: &A, ty: &Type) -> BNRef<BNType> { BNConf::new(0, 0), ) } - None => BNType::function(&return_type, params, variable_args), + (_, _) => BNType::function(&return_type, params, variable_args), } } TypeClass::Referrer(c) => { diff --git a/plugins/warp/src/matcher.rs b/plugins/warp/src/matcher.rs index 52a5b04f..d97b4a18 100644 --- a/plugins/warp/src/matcher.rs +++ b/plugins/warp/src/matcher.rs @@ -100,21 +100,21 @@ impl Matcher { // TODO: I would really like for WARP types to be added in a seperate type container, so that we don't // TODO: just add them as system or user types. - pub fn add_type_to_view<A: BNArchitecture>( + pub fn add_type_to_view<A: BNArchitecture + Copy>( &self, container: &dyn Container, source: &SourceId, view: &BinaryView, - arch: &A, + arch: A, ty: &Type, ) where Self: Sized, { - fn inner_add_type_to_view<A: BNArchitecture>( + fn inner_add_type_to_view<A: BNArchitecture + Copy>( container: &dyn Container, source: &SourceId, view: &BinaryView, - arch: &A, + arch: A, visited_refs: &mut HashSet<String>, ty: &Type, ) { @@ -251,7 +251,7 @@ impl Matcher { view.define_auto_type_with_id( name, &guid.to_string(), - &to_bn_type(arch, &ref_ty), + &to_bn_type(Some(arch), &ref_ty), ); } (Some(_guid), Some(_name), None) => { diff --git a/plugins/warp/src/plugin.rs b/plugins/warp/src/plugin.rs index 7a2bdcff..229b0ae0 100644 --- a/plugins/warp/src/plugin.rs +++ b/plugins/warp/src/plugin.rs @@ -14,10 +14,11 @@ use binaryninja::command::{ }; use binaryninja::is_ui_enabled; use binaryninja::logger::Logger; -use binaryninja::settings::Settings; +use binaryninja::settings::{QueryOptions, Settings}; use log::LevelFilter; use reqwest::StatusCode; +mod commit; mod create; mod debug; mod ffi; @@ -31,17 +32,21 @@ mod workflow; fn load_bundled_signatures() { let global_bn_settings = Settings::new(); - let plugin_settings = PluginSettings::from_settings(&global_bn_settings); + let plugin_settings = + PluginSettings::from_settings(&global_bn_settings, &mut QueryOptions::new()); // We want to load all the bundled directories into the container cache. let background_task = BackgroundTask::new("Loading WARP files...", false); let start = Instant::now(); if plugin_settings.load_bundled_files { - let core_disk_container = DiskContainer::new_from_dir(core_signature_dir()); + let mut core_disk_container = DiskContainer::new_from_dir(core_signature_dir()); + core_disk_container.name = "Bundled".to_string(); + core_disk_container.writable = false; log::debug!("{:#?}", core_disk_container); add_cached_container(core_disk_container); } if plugin_settings.load_user_files { - let user_disk_container = DiskContainer::new_from_dir(user_signature_dir()); + let mut user_disk_container = DiskContainer::new_from_dir(user_signature_dir()); + user_disk_container.name = "User".to_string(); log::debug!("{:#?}", user_disk_container); add_cached_container(user_disk_container); } @@ -51,36 +56,77 @@ fn load_bundled_signatures() { fn load_network_container() { let global_bn_settings = Settings::new(); - let plugin_settings = PluginSettings::from_settings(&global_bn_settings); - let background_task = BackgroundTask::new("Initializing WARP server...", false); - let start = Instant::now(); - if plugin_settings.enable_server { - let server_url = plugin_settings.server_url.clone(); - let server_api_key = plugin_settings.server_api_key.clone(); + + let add_network_container = |url: String, api_key: Option<String>| { let https_proxy_str = global_bn_settings.get_string("network.httpsProxy"); let https_proxy = if https_proxy_str.is_empty() { None } else { Some(https_proxy_str) }; - match NetworkClient::new(server_url.clone(), server_api_key, https_proxy) { + match NetworkClient::new(url.clone(), api_key.clone(), https_proxy) { Ok(network_client) => { // Before constructing the container, let's make sure that the server is OK. if let Ok(StatusCode::OK) = network_client.status() { - let network_container = NetworkContainer::new(network_client); + // Check if the user is logged in. If so, we should collect the writable sources. + let mut writable_sources = Vec::new(); + match network_client.current_user() { + Ok((id, username)) => { + log::info!( + "Server '{}' connected, logged in as user '{}'", + url, + username + ); + match network_client.query_sources(Some(id)) { + Ok(sources) => { + writable_sources = sources; + } + Err(e) => { + log::error!( + "Server '{}' failed to get sources for user: {}", + url, + e + ); + } + } + } + Err(e) if api_key.is_some() => { + log::error!( + "Server '{}' failed to authenticate with provided API key: {}", + url, + e + ); + } + Err(_) => { + log::info!("Server '{}' connected, logged in as guest", url); + } + } + + // TODO: Make the cache path include the domain or url, so that we can have multiple servers. + let main_cache_path = NetworkContainer::root_cache_location().join("main"); + let network_container = + NetworkContainer::new(network_client, main_cache_path, &writable_sources); log::debug!("{:#?}", network_container); add_cached_container(network_container); } else { - log::error!( - "Server '{}' is not reachable, disabling container...", - server_url - ); + log::error!("Server '{}' is not reachable, disabling container...", url); } } Err(e) => { log::error!("Failed to add networked container: {}", e); } } + }; + + let plugin_settings = + PluginSettings::from_settings(&global_bn_settings, &mut QueryOptions::new()); + let background_task = BackgroundTask::new("Initializing WARP server...", false); + let start = Instant::now(); + if plugin_settings.enable_server { + add_network_container(plugin_settings.server_url, plugin_settings.server_api_key); + if let Some(second_server_url) = plugin_settings.second_server_url { + add_network_container(second_server_url, plugin_settings.second_server_api_key); + } } log::debug!("Initializing warp server took {:?}", start.elapsed()); background_task.finish(); @@ -156,6 +202,12 @@ pub extern "C" fn CorePluginInit() -> bool { load::LoadSignatureFile {}, ); + register_command( + "WARP\\Commit File", + "Commit file to a source", + commit::CommitFile {}, + ); + register_command_for_function( "WARP\\Include Function", "Add current function to the list of functions to add to the signature file", diff --git a/plugins/warp/src/plugin/commit.rs b/plugins/warp/src/plugin/commit.rs new file mode 100644 index 00000000..2af5fc3e --- /dev/null +++ b/plugins/warp/src/plugin/commit.rs @@ -0,0 +1,150 @@ +//! Commit file to a source. + +use crate::cache::container::cached_containers; +use crate::container::{SourceId, SourcePath}; +use crate::plugin::create::OpenFileField; +use binaryninja::binary_view::BinaryView; +use binaryninja::command::Command; +use binaryninja::interaction::{Form, FormInputField}; +use warp::chunk::ChunkKind; +use warp::WarpFile; + +pub struct SelectedSourceField { + sources: Vec<(SourceId, SourcePath)>, +} + +impl SelectedSourceField { + pub fn field(&self) -> FormInputField { + FormInputField::Choice { + prompt: "Selected Source".to_string(), + choices: self + .sources + .iter() + .map(|(id, path)| { + // For display purposes we only want to show the last path item. + let path_name = path + .to_string() + .rsplit_once('/') + .map_or(path.to_string(), |(_, last_path_item)| { + last_path_item.to_string() + }); + // TODO: Probably have a truncation limit here, this is just for display after all. + format!("{} ({})", path_name, id) + }) + .collect(), + default: None, + value: 0, + } + } + + pub fn from_form(&self, form: &Form) -> Option<SourceId> { + let field = form.get_field_with_name("Selected Source")?; + let field_value = field.try_value_index()?; + self.sources.get(field_value).map(|(id, _)| *id) + } +} + +pub struct CommitFile; + +impl CommitFile { + pub fn selected_source_field() -> SelectedSourceField { + let mut writable_sources = Vec::new(); + for container in cached_containers() { + if let Ok(container) = container.read() { + for source in container.sources().unwrap_or_default() { + if let Ok(true) = container.is_source_writable(&source) { + if let Ok(source_path) = container.source_path(&source) { + writable_sources.push((source, source_path)); + } + } + } + } + } + SelectedSourceField { + sources: writable_sources, + } + } + + pub fn execute() -> Option<()> { + let mut form = Form::new("Commit File"); + + // Users are going to get confused between this and adding functions to a source then commiting. + // So we should make it clear with a label, and also probably deprecate this command and replace it with "add functions to source" and "commit source". + form.add_field(FormInputField::Label { + prompt: "Commits a WARP file to an existing source, this is primarily used for committing to network containers".to_string() + }); + + form.add_field(OpenFileField::field()); + let source_field = Self::selected_source_field(); + form.add_field(source_field.field()); + + if !form.prompt() { + return None; + } + + let open_file_path = OpenFileField::from_form(&form)?; + let source_id = source_field.from_form(&form)?; + log::info!("Committing file to source: {}", source_id); + + let bytes = std::fs::read(open_file_path).ok()?; + let Some(warp_file) = WarpFile::from_bytes(&bytes) else { + log::error!("Failed to parse warp file!"); + return None; + }; + + for container in cached_containers() { + let Ok(mut container) = container.write() else { + continue; + }; + + if let Ok(true) = container.is_source_writable(&source_id) { + // TODO: We need to find a sane way to do this procedure through the FFI. + for chunk in &warp_file.chunks { + match &chunk.kind { + ChunkKind::Signature(sc) => { + let functions: Vec<_> = sc.functions().collect(); + log::info!( + "Adding {} functions to source: {}", + functions.len(), + source_id + ); + if let Err(e) = container.add_functions( + &chunk.header.target, + &source_id, + &functions, + ) { + log::error!("Failed to add functions to source: {}", e); + } + } + ChunkKind::Type(sc) => { + let types: Vec<_> = sc.types().collect(); + log::info!("Adding {} types to source: {}", types.len(), source_id); + if let Err(e) = container.add_computed_types(&source_id, &types) { + log::error!("Failed to add types to source: {}", e); + } + } + } + } + if let Err(e) = container.commit_source(&source_id) { + log::error!("Failed to commit source: {}", e); + } + log::info!("Committed file to source: {}", source_id); + return Some(()); + } + } + + Some(()) + } +} + +impl Command for CommitFile { + fn action(&self, _view: &BinaryView) { + std::thread::spawn(move || { + Self::execute(); + }); + } + + fn valid(&self, _view: &BinaryView) -> bool { + true + } +} diff --git a/plugins/warp/src/plugin/ffi.rs b/plugins/warp/src/plugin/ffi.rs index 2826ed34..c1f5acb8 100644 --- a/plugins/warp/src/plugin/ffi.rs +++ b/plugins/warp/src/plugin/ffi.rs @@ -1,4 +1,5 @@ mod container; +mod file; mod function; use binaryninjacore_sys::{ @@ -88,6 +89,17 @@ pub unsafe extern "C" fn BNWARPUUIDGetString(uuid: *const Uuid) -> *mut c_char { } #[no_mangle] +pub unsafe extern "C" fn BNWARPUUIDFromString(uuid_str: *mut c_char, uuid: *mut Uuid) -> bool { + if let Ok(uuid_str) = std::ffi::CStr::from_ptr(uuid_str).to_str() { + if let Some(parsed_uuid) = Uuid::parse_str(uuid_str).ok() { + *uuid = parsed_uuid; + return true; + } + } + false +} + +#[no_mangle] pub unsafe extern "C" fn BNWARPUUIDEqual(a: *const Uuid, b: *const Uuid) -> bool { (*a) == (*b) } diff --git a/plugins/warp/src/plugin/ffi/container.rs b/plugins/warp/src/plugin/ffi/container.rs index 4de6bab3..72c85366 100644 --- a/plugins/warp/src/plugin/ffi/container.rs +++ b/plugins/warp/src/plugin/ffi/container.rs @@ -1,5 +1,7 @@ use crate::cache::container::cached_containers; -use crate::container::SourcePath; +use crate::container::{ + ContainerSearchItem, ContainerSearchItemKind, ContainerSearchQuery, SourcePath, SourceTag, +}; use crate::convert::{from_bn_type, to_bn_type}; use crate::plugin::ffi::{ BNWARPContainer, BNWARPFunction, BNWARPFunctionGUID, BNWARPSource, BNWARPTarget, BNWARPTypeGUID, @@ -12,7 +14,163 @@ use binaryninja::types::Type; use binaryninjacore_sys::{BNArchitecture, BNBinaryView, BNType}; use std::ffi::{c_char, CStr}; use std::mem::ManuallyDrop; +use std::ops::Deref; use std::sync::Arc; +use warp::r#type::guid::TypeGUID; + +pub type BNWARPContainerSearchQuery = ContainerSearchQuery; +pub type BNWARPContainerSearchItem = ContainerSearchItem; + +#[repr(C)] +pub enum BNWARPContainerSearchItemKind { + Source = 0, + Function = 1, + Type = 2, + Symbol = 3, +} + +#[repr(C)] +pub struct BNWARPContainerSearchResponse { + pub count: usize, + pub items: *mut *mut BNWARPContainerSearchItem, + pub offset: usize, + pub total: usize, +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPNewContainerSearchQuery( + query: *mut c_char, + offset: *const usize, + limit: *const usize, + source: *const BNWARPSource, + source_tags: *mut *mut c_char, + source_tags_count: usize, +) -> *mut BNWARPContainerSearchQuery { + let query_cstr = unsafe { CStr::from_ptr(query) }; + let Ok(query) = query_cstr.to_str() else { + return std::ptr::null_mut(); + }; + let mut search_query = ContainerSearchQuery::new(query.to_string()); + if !offset.is_null() { + search_query.offset = Some(*offset); + } + if !limit.is_null() { + search_query.limit = Some(*limit); + } + if !source.is_null() { + search_query.source = Some(*source); + } + if !source_tags.is_null() { + let source_tags_raw = unsafe { std::slice::from_raw_parts(source_tags, source_tags_count) }; + let source_tags: Vec<SourceTag> = source_tags_raw + .iter() + .filter_map(|&ptr| CStr::from_ptr(ptr).to_str().ok()) + .map(|s| s.into()) + .collect(); + search_query.tags = source_tags; + } + Box::into_raw(Box::new(search_query)) +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearchItemGetKind( + item: *mut BNWARPContainerSearchItem, +) -> BNWARPContainerSearchItemKind { + let item = ManuallyDrop::new(Arc::from_raw(item)); + match &item.kind { + ContainerSearchItemKind::Source { .. } => BNWARPContainerSearchItemKind::Source, + ContainerSearchItemKind::Function(_) => BNWARPContainerSearchItemKind::Function, + ContainerSearchItemKind::Type(_) => BNWARPContainerSearchItemKind::Type, + ContainerSearchItemKind::Symbol(_) => BNWARPContainerSearchItemKind::Symbol, + } +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearchItemGetSource( + item: *mut BNWARPContainerSearchItem, +) -> BNWARPSource { + let item = ManuallyDrop::new(Arc::from_raw(item)); + item.source +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearchItemGetType( + arch: *mut BNArchitecture, + item: *mut BNWARPContainerSearchItem, +) -> *mut BNType { + // NOTE: to convert the type, we must have an architecture. + let arch = match !arch.is_null() { + true => Some(CoreArchitecture::from_raw(arch)), + false => None, + }; + + let item = ManuallyDrop::new(Arc::from_raw(item)); + match &item.kind { + ContainerSearchItemKind::Source { .. } => std::ptr::null_mut(), + ContainerSearchItemKind::Function(func) => { + match &func.ty { + None => std::ptr::null_mut(), + Some(ty) => { + let bn_ty = to_bn_type(arch, &ty); + // NOTE: The type ref has been pre-incremented for the caller. + unsafe { Ref::into_raw(bn_ty) }.handle + } + } + } + ContainerSearchItemKind::Type(ty) => { + let bn_ty = to_bn_type(arch, &ty); + // NOTE: The type ref has been pre-incremented for the caller. + unsafe { Ref::into_raw(bn_ty) }.handle + } + ContainerSearchItemKind::Symbol(_) => std::ptr::null_mut(), + } +} + +// NOTE: In the future we should allow for the possibility of this returning a null pointer. +#[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearchItemGetName( + item: *mut BNWARPContainerSearchItem, +) -> *mut c_char { + let item = ManuallyDrop::new(Arc::from_raw(item)); + match &item.kind { + ContainerSearchItemKind::Source { path, .. } => { + let bn_name = BnString::new(path.to_string()); + BnString::into_raw(bn_name) + } + ContainerSearchItemKind::Function(func) => { + let bn_name = BnString::new(func.symbol.name.clone()); + BnString::into_raw(bn_name) + } + ContainerSearchItemKind::Type(ty) => { + // TODO: Maybe un-named types should return std::ptr::null_mut()? + let ty_name = ty + .name + .clone() + .unwrap_or_else(|| TypeGUID::from(ty).to_string()); + let bn_name = BnString::new(ty_name); + BnString::into_raw(bn_name) + } + ContainerSearchItemKind::Symbol(sym) => { + let bn_name = BnString::new(sym.name.clone()); + BnString::into_raw(bn_name) + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearchItemGetFunction( + item: *mut BNWARPContainerSearchItem, +) -> *mut BNWARPFunction { + let item = ManuallyDrop::new(Arc::from_raw(item)); + match &item.kind { + ContainerSearchItemKind::Source { .. } => std::ptr::null_mut(), + ContainerSearchItemKind::Function(func) => { + Arc::into_raw(Arc::new(func.clone())) as *mut BNWARPFunction + } + ContainerSearchItemKind::Type(_) => std::ptr::null_mut(), + ContainerSearchItemKind::Symbol(_) => std::ptr::null_mut(), + } +} #[no_mangle] pub unsafe extern "C" fn BNWARPGetContainers(count: *mut usize) -> *mut *mut BNWARPContainer { @@ -39,6 +197,8 @@ pub unsafe extern "C" fn BNWARPContainerGetName(container: *mut BNWARPContainer) pub unsafe extern "C" fn BNWARPContainerFetchFunctions( container: *mut BNWARPContainer, target: *mut BNWARPTarget, + source_tags: *mut *mut c_char, + source_tags_count: usize, guids: *const BNWARPFunctionGUID, count: usize, ) { @@ -49,9 +209,16 @@ pub unsafe extern "C" fn BNWARPContainerFetchFunctions( let target = unsafe { ManuallyDrop::new(Arc::from_raw(target)) }; + let source_tags_raw = unsafe { std::slice::from_raw_parts(source_tags, source_tags_count) }; + let source_tags: Vec<SourceTag> = source_tags_raw + .iter() + .filter_map(|&ptr| CStr::from_ptr(ptr).to_str().ok()) + .map(|s| s.into()) + .collect(); + let guids = unsafe { std::slice::from_raw_parts(guids, count) }; - if let Err(e) = container.fetch_functions(&target, guids) { + if let Err(e) = container.fetch_functions(&target, &source_tags, guids) { log::error!("Failed to fetch functions: {}", e); } } @@ -373,7 +540,7 @@ pub unsafe extern "C" fn BNWARPContainerGetTypeWithGUID( let Some(ty) = container.type_with_guid(&source, &guid).unwrap_or_default() else { return std::ptr::null_mut(); }; - let function_type = to_bn_type(&arch, &ty); + let function_type = to_bn_type(Some(arch), &ty); // NOTE: The type ref has been pre-incremented for the caller. unsafe { Ref::into_raw(function_type) }.handle } @@ -405,6 +572,45 @@ pub unsafe extern "C" fn BNWARPContainerGetTypeGUIDsWithName( } #[no_mangle] +pub unsafe extern "C" fn BNWARPContainerSearch( + container: *mut BNWARPContainer, + query: *mut BNWARPContainerSearchQuery, +) -> *mut BNWARPContainerSearchResponse { + let arc_container = ManuallyDrop::new(Arc::from_raw(container)); + let Ok(container) = arc_container.read() else { + return std::ptr::null_mut(); + }; + + let query = unsafe { ManuallyDrop::new(Arc::from_raw(query)) }; + + let result = match container.search(&query) { + Ok(result) => result, + Err(err) => { + log::error!("Failed to search container {:?}: {}", query.deref(), err); + return std::ptr::null_mut(); + } + }; + + let boxed_raw_items: Box<[_]> = result + .items + .into_iter() + .map(Arc::new) + .map(Arc::into_raw) + .collect(); + let count = boxed_raw_items.len(); + // NOTE: Leak the functions to be freed by BNWARPFreeContainerSearchItemList + let leaked_raw_items = Box::into_raw(boxed_raw_items) as *mut *mut BNWARPContainerSearchItem; + let raw_result = BNWARPContainerSearchResponse { + count, + items: leaked_raw_items, + total: result.total, + offset: result.offset, + }; + // NOTE: Leak the result to be freed by BNWARPFreeContainerSearchResult + Box::into_raw(Box::new(raw_result)) +} + +#[no_mangle] pub unsafe extern "C" fn BNWARPNewContainerReference( container: *mut BNWARPContainer, ) -> *mut BNWARPContainer { @@ -431,3 +637,62 @@ pub unsafe extern "C" fn BNWARPFreeContainerList( BNWARPFreeContainerReference(container); } } + +#[no_mangle] +pub unsafe extern "C" fn BNWARPNewContainerSearchQueryReference( + query: *mut BNWARPContainerSearchQuery, +) -> *mut BNWARPContainerSearchQuery { + Arc::increment_strong_count(query); + query +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPFreeContainerSearchQueryReference( + query: *mut BNWARPContainerSearchQuery, +) { + if query.is_null() { + return; + } + Arc::decrement_strong_count(query); +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPNewContainerSearchItemReference( + item: *mut BNWARPContainerSearchItem, +) -> *mut BNWARPContainerSearchItem { + Arc::increment_strong_count(item); + item +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPFreeContainerSearchItemReference( + item: *mut BNWARPContainerSearchItem, +) { + if item.is_null() { + return; + } + Arc::decrement_strong_count(item); +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPFreeContainerSearchItemList( + items: *mut *mut BNWARPContainerSearchItem, + count: usize, +) { + let items_ptr = std::ptr::slice_from_raw_parts_mut(items, count); + let items = unsafe { Box::from_raw(items_ptr) }; + for item in items { + BNWARPFreeContainerSearchItemReference(item); + } +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPFreeContainerSearchResponse( + response: *mut BNWARPContainerSearchResponse, +) { + if response.is_null() { + return; + } + let response = unsafe { Box::from_raw(response) }; + BNWARPFreeContainerSearchItemList(response.items, response.count); +} diff --git a/plugins/warp/src/plugin/ffi/file.rs b/plugins/warp/src/plugin/ffi/file.rs new file mode 100644 index 00000000..951b5eb2 --- /dev/null +++ b/plugins/warp/src/plugin/ffi/file.rs @@ -0,0 +1,38 @@ +use std::ffi::c_char; +use std::sync::Arc; +use warp::WarpFile; + +pub type BNWARPFile = WarpFile<'static>; + +// TODO: At some point we may want to expose chunks directly. For now we will just enumerate all of them. +// pub type BNWARPChunk = warp::chunk::Chunk<'static>; + +// TODO: From bytes as well. +#[no_mangle] +pub unsafe extern "C" fn BNWARPNewFileFromPath(path: *mut c_char) -> *mut BNWARPFile { + let path_cstr = unsafe { std::ffi::CStr::from_ptr(path) }; + let Ok(path) = path_cstr.to_str() else { + return std::ptr::null_mut(); + }; + let Ok(bytes) = std::fs::read(path) else { + return std::ptr::null_mut(); + }; + let Some(file) = WarpFile::from_owned_bytes(bytes) else { + return std::ptr::null_mut(); + }; + Arc::into_raw(Arc::new(file)) as *mut BNWARPFile +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPNewFileReference(file: *mut BNWARPFile) -> *mut BNWARPFile { + Arc::increment_strong_count(file); + file +} + +#[no_mangle] +pub unsafe extern "C" fn BNWARPFreeFileReference(file: *mut BNWARPFile) { + if file.is_null() { + return; + } + Arc::decrement_strong_count(file); +} diff --git a/plugins/warp/src/plugin/ffi/function.rs b/plugins/warp/src/plugin/ffi/function.rs index b37d613b..e3f6a079 100644 --- a/plugins/warp/src/plugin/ffi/function.rs +++ b/plugins/warp/src/plugin/ffi/function.rs @@ -124,7 +124,7 @@ pub unsafe extern "C" fn BNWARPFunctionGetType( match &function.ty { Some(func_ty) => { let arch = analysis_function.arch(); - let function_type = to_bn_type(&arch, func_ty); + let function_type = to_bn_type(Some(arch), func_ty); // NOTE: The type ref has been pre-incremented for the caller. unsafe { Ref::into_raw(function_type) }.handle } diff --git a/plugins/warp/src/plugin/load.rs b/plugins/warp/src/plugin/load.rs index cef6ea11..6f8b562e 100644 --- a/plugins/warp/src/plugin/load.rs +++ b/plugins/warp/src/plugin/load.rs @@ -40,16 +40,15 @@ pub struct RunMatcherField; impl RunMatcherField { pub fn field() -> FormInputField { - FormInputField::Choice { - prompt: "Rerun Initial Matcher".to_string(), - choices: vec!["No".to_string(), "Yes".to_string()], - default: Some(1), - value: 0, + FormInputField::Checkbox { + prompt: "Rerun Matcher".to_string(), + default: Some(true), + value: false, } } pub fn from_form(form: &Form) -> Option<bool> { - let field = form.get_field_with_name("Rerun Initial Matcher")?; + let field = form.get_field_with_name("Rerun Matcher")?; let field_value = field.try_value_index()?; match field_value { 1 => Some(true), diff --git a/plugins/warp/src/plugin/settings.rs b/plugins/warp/src/plugin/settings.rs index 489deb23..39092177 100644 --- a/plugins/warp/src/plugin/settings.rs +++ b/plugins/warp/src/plugin/settings.rs @@ -1,4 +1,4 @@ -use binaryninja::settings::Settings as BNSettings; +use binaryninja::settings::{QueryOptions, Settings as BNSettings}; use serde_json::json; use std::string::ToString; @@ -20,6 +20,12 @@ pub struct PluginSettings { /// /// This is set to [PluginSettings::SERVER_API_KEY_DEFAULT] by default. pub server_api_key: Option<String>, + pub second_server_url: Option<String>, + pub second_server_api_key: Option<String>, + /// A source must have at least one of these tags to be considered a valid source. + /// + /// This is set to [PluginSettings::SOURCE_TAGS_DEFAULT] by default. + pub whitelisted_source_tags: Vec<String>, /// Whether to allow networked WARP requests. Turning this off will not disable local WARP functionality. /// /// This is set to [PluginSettings::ENABLE_SERVER_DEFAULT] by default. @@ -27,6 +33,8 @@ pub struct PluginSettings { } impl PluginSettings { + pub const WHITELISTED_SOURCE_TAGS_DEFAULT: Vec<String> = vec![]; + pub const WHITELISTED_SOURCE_TAGS_SETTING: &'static str = "analysis.warp.whitelistedSourceTags"; pub const LOAD_BUNDLED_FILES_DEFAULT: bool = true; pub const LOAD_BUNDLED_FILES_SETTING: &'static str = "analysis.warp.loadBundledFiles"; pub const LOAD_USER_FILES_DEFAULT: bool = true; @@ -35,10 +43,25 @@ impl PluginSettings { pub const SERVER_URL_SETTING: &'static str = "analysis.warp.serverUrl"; pub const SERVER_API_KEY_DEFAULT: Option<String> = None; pub const SERVER_API_KEY_SETTING: &'static str = "analysis.warp.serverApiKey"; + pub const SECONDARY_SERVER_URL_DEFAULT: Option<String> = None; + pub const SECONDARY_SERVER_URL_SETTING: &'static str = "analysis.warp.secondServerUrl"; + pub const SECONDARY_SERVER_API_KEY_DEFAULT: Option<String> = None; + pub const SECONDARY_SERVER_API_KEY_SETTING: &'static str = "analysis.warp.secondServerApiKey"; pub const ENABLE_SERVER_DEFAULT: bool = false; pub const ENABLE_SERVER_SETTING: &'static str = "network.enableWARP"; pub fn register(bn_settings: &mut BNSettings) { + let whitelisted_source_tags_prop = json!({ + "title" : "Blacklisted Sources", + "type" : "array", + "default" : Self::WHITELISTED_SOURCE_TAGS_DEFAULT, + "description" : "Add a sources UUID to this list to blacklist it from being considered a valid source. This is useful for sources that are known to be false positives.", + "ignore" : [], + }); + bn_settings.register_setting_json( + Self::WHITELISTED_SOURCE_TAGS_SETTING, + &whitelisted_source_tags_prop.to_string(), + ); let load_bundled_files_prop = json!({ "title" : "Load Bundled Files", "type" : "boolean", @@ -85,6 +108,31 @@ impl PluginSettings { Self::SERVER_API_KEY_SETTING, &server_api_key_prop.to_string(), ); + let second_server_url_prop = json!({ + "title" : "Secondary Server URL", + "type" : "string", + "default" : Self::SECONDARY_SERVER_URL_DEFAULT, + "description" : "", + "ignore" : ["SettingsProjectScope", "SettingsResourceScope"], + "requiresRestart" : true + }); + bn_settings.register_setting_json( + Self::SECONDARY_SERVER_URL_SETTING, + &second_server_url_prop.to_string(), + ); + let second_server_api_key_prop = json!({ + "title" : "Secondary Server API Key", + "type" : "string", + "default" : Self::SECONDARY_SERVER_API_KEY_DEFAULT, + "description" : "", + "ignore" : ["SettingsProjectScope", "SettingsResourceScope"], + "hidden": true, + "requiresRestart" : true + }); + bn_settings.register_setting_json( + Self::SECONDARY_SERVER_API_KEY_SETTING, + &second_server_api_key_prop.to_string(), + ); let server_enabled_prop = json!({ "title" : "Enable WARP", "type" : "boolean", @@ -100,7 +148,7 @@ impl PluginSettings { } /// Retrieve plugin settings from [`BNSettings`]. - pub fn from_settings(bn_settings: &BNSettings) -> Self { + pub fn from_settings(bn_settings: &BNSettings, query_opts: &mut QueryOptions) -> Self { let mut settings = PluginSettings::default(); if bn_settings.contains(Self::LOAD_BUNDLED_FILES_SETTING) { settings.load_bundled_files = bn_settings.get_bool(Self::LOAD_BUNDLED_FILES_SETTING); @@ -117,9 +165,30 @@ impl PluginSettings { settings.server_api_key = Some(server_api_key_str); } } + if bn_settings.contains(Self::SECONDARY_SERVER_URL_SETTING) { + let server_api_key_str = bn_settings.get_string(Self::SECONDARY_SERVER_URL_SETTING); + if !server_api_key_str.is_empty() { + settings.second_server_url = Some(server_api_key_str); + } + } + if bn_settings.contains(Self::SECONDARY_SERVER_API_KEY_SETTING) { + let server_api_key_str = bn_settings.get_string(Self::SECONDARY_SERVER_API_KEY_SETTING); + if !server_api_key_str.is_empty() { + settings.second_server_api_key = Some(server_api_key_str); + } + } if bn_settings.contains(Self::ENABLE_SERVER_SETTING) { settings.enable_server = bn_settings.get_bool(Self::ENABLE_SERVER_SETTING); } + + if bn_settings.contains(Self::WHITELISTED_SOURCE_TAGS_SETTING) { + let whitelisted_source_tags_str = bn_settings + .get_string_list_with_opts(Self::WHITELISTED_SOURCE_TAGS_SETTING, query_opts); + settings.whitelisted_source_tags = whitelisted_source_tags_str + .iter() + .map(|s| s.to_string()) + .collect(); + } settings } } @@ -127,10 +196,13 @@ impl PluginSettings { impl Default for PluginSettings { fn default() -> Self { Self { + whitelisted_source_tags: PluginSettings::WHITELISTED_SOURCE_TAGS_DEFAULT, load_bundled_files: PluginSettings::LOAD_BUNDLED_FILES_DEFAULT, load_user_files: PluginSettings::LOAD_USER_FILES_DEFAULT, server_url: PluginSettings::SERVER_URL_DEFAULT.to_string(), server_api_key: PluginSettings::SERVER_API_KEY_DEFAULT, + second_server_url: PluginSettings::SECONDARY_SERVER_URL_DEFAULT, + second_server_api_key: PluginSettings::SECONDARY_SERVER_API_KEY_DEFAULT, enable_server: PluginSettings::ENABLE_SERVER_DEFAULT, } } diff --git a/plugins/warp/src/plugin/workflow.rs b/plugins/warp/src/plugin/workflow.rs index 9871f5d2..08d07c52 100644 --- a/plugins/warp/src/plugin/workflow.rs +++ b/plugins/warp/src/plugin/workflow.rs @@ -162,11 +162,7 @@ pub fn run_matcher(view: &BinaryView) { log::info!("Matcher was cancelled by user, you may run it again by running the 'Run Matcher' command."); } - // It is noisy to show this every time, so we only show it in cases where a user can reasonably perceive. - let elapsed = start.elapsed(); - if elapsed > std::time::Duration::from_secs(1) { - log::info!("Function matching took {:?}", elapsed); - } + log::info!("Function matching took {:?}", start.elapsed()); background_task.finish(); // Now we want to trigger re-analysis. @@ -185,7 +181,7 @@ pub fn insert_workflow() -> Result<(), ()> { // otherwise we will wipe over user type info. if !function.has_user_type() { if let Some(func_ty) = &matched_function.ty { - function.set_auto_type(&to_bn_type(&function.arch(), func_ty)); + function.set_auto_type(&to_bn_type(Some(function.arch()), func_ty)); } } if let Some(mlil) = ctx.mlil_function() { @@ -206,7 +202,7 @@ pub fn insert_workflow() -> Result<(), ()> { continue; } let decl_ty = match variable.ty { - Some(decl_ty) => to_bn_type(&function.arch(), &decl_ty), + Some(decl_ty) => to_bn_type(Some(function.arch()), &decl_ty), None => { let Some(existing_var) = function.variable_type(&decl_var) else { continue; diff --git a/plugins/warp/tests/determinism.rs b/plugins/warp/tests/determinism.rs index e0568794..5100b436 100644 --- a/plugins/warp/tests/determinism.rs +++ b/plugins/warp/tests/determinism.rs @@ -32,8 +32,8 @@ fn insta_signatures() { .functions() .iter() .map(|f| { - let guid = cached_function_guid(&f, &f.lifted_il().unwrap()); - (f.start(), guid) + let guid = cached_function_guid(&f, || f.lifted_il().ok()); + (f.start(), guid.unwrap()) }) .collect(); insta::assert_debug_snapshot!(file_name, functions); diff --git a/plugins/warp/tests/matcher.rs b/plugins/warp/tests/matcher.rs index 13e2ce01..880112d0 100644 --- a/plugins/warp/tests/matcher.rs +++ b/plugins/warp/tests/matcher.rs @@ -169,7 +169,7 @@ fn test_add_type_to_view() { .guid(struct_type_guid) .build(); let ref_type = Type::builder().name("my_ref").class(ref_class).build(); - matcher.add_type_to_view(&container, &source, &view, &arch, &ref_type); + matcher.add_type_to_view(&container, &source, &view, arch, &ref_type); println!("{:#?}", view.types().to_vec()); diff --git a/plugins/warp/ui/CMakeLists.txt b/plugins/warp/ui/CMakeLists.txt index c3e5bcf6..88cc6a60 100644 --- a/plugins/warp/ui/CMakeLists.txt +++ b/plugins/warp/ui/CMakeLists.txt @@ -9,7 +9,10 @@ file(GLOB SOURCES CONFIGURE_DEPENDS shared/misc.cpp shared/misc.h shared/constraint.cpp shared/constraint.h shared/function.cpp shared/function.h - shared/container.cpp shared/container.h) + containers.cpp containers.h + shared/search.cpp shared/search.h + shared/fetcher.cpp shared/fetcher.h + shared/fetchdialog.cpp shared/fetchdialog.h) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) diff --git a/plugins/warp/ui/containers.cpp b/plugins/warp/ui/containers.cpp new file mode 100644 index 00000000..689d695f --- /dev/null +++ b/plugins/warp/ui/containers.cpp @@ -0,0 +1,274 @@ +#include "containers.h" + +QVariant WarpSourcesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) + return {}; + if (index.row() < 0 || index.row() >= rowCount()) + return {}; + + const auto &r = m_rows[static_cast<size_t>(index.row())]; + + // Build a small two-dot status icon (left: writable, right: uncommitted) + auto statusIcon = [](bool writable, bool uncommitted) -> QIcon { + static QIcon cache[2][2]; // [writable][uncommitted] + QIcon &cached = cache[writable ? 1 : 0][uncommitted ? 1 : 0]; + if (!cached.isNull()) + return cached; + + const int w = 16, h = 12, radius = 4; + QPixmap pm(w, h); + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + + // Colors + QColor writableOn(76, 175, 80); // green + QColor writableOff(158, 158, 158); // grey + QColor uncommittedOn(255, 193, 7); // amber + QColor uncommittedOff(158, 158, 158); // grey + + // Left dot: writable + p.setBrush(writable ? writableOn : writableOff); + p.setPen(Qt::NoPen); + p.drawEllipse(QPoint(4, h / 2), radius, radius); + + // Right dot: uncommitted + p.setBrush(uncommitted ? uncommittedOn : uncommittedOff); + p.drawEllipse(QPoint(w - 6, h / 2), radius, radius); + + p.end(); + cached = QIcon(pm); + return cached; + }; + + if (role == Qt::DecorationRole && index.column() == PathCol) + { + return statusIcon(r.writable, r.uncommitted); + } + + if (role == Qt::ToolTipRole && index.column() == PathCol) + { + QStringList parts; + parts << (r.writable ? "Writable" : "Read-only"); + parts << (r.uncommitted ? "Uncommitted changes" : "No uncommitted changes"); + return parts.join(" • "); + } + + if (role == Qt::DisplayRole) + { + switch (index.column()) + { + case GuidCol: return r.guid; + case PathCol: return r.path; + case WritableCol: return r.writable ? "Yes" : "No"; + case UncommittedCol: return r.uncommitted ? "Yes" : "No"; + default: return {}; + } + } + + if (role == Qt::CheckStateRole) + { + // Optional: expose as checkboxes if someone ever shows these columns + switch (index.column()) + { + case WritableCol: return r.writable ? Qt::Checked : Qt::Unchecked; + case UncommittedCol: return r.uncommitted ? Qt::Checked : Qt::Unchecked; + default: break; + } + } + + return {}; +} + +WarpContainerWidget::WarpContainerWidget(Warp::Ref<Warp::Container> container, QWidget *parent) : QWidget(parent) +{ + m_container = std::move(container); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_tabs = new QTabWidget(this); + layout->addWidget(m_tabs); + + // Sources tab + m_sourcesPage = new QWidget(this); + auto *sourcesLayout = new QVBoxLayout(m_sourcesPage); + m_sourcesView = new QTableView(m_sourcesPage); + m_sourcesModel = new WarpSourcesModel(m_sourcesPage); + m_sourcesModel->setContainer(m_container); + m_sourcesView->setModel(m_sourcesModel); + m_sourcesView->horizontalHeader()->setStretchLastSection(true); + m_sourcesView->setSelectionBehavior(QAbstractItemView::SelectRows); + m_sourcesView->setSelectionMode(QAbstractItemView::SingleSelection); + + // Make the table look like a simple list that shows only the source path + m_sourcesView->setShowGrid(false); + m_sourcesView->verticalHeader()->setVisible(false); + m_sourcesView->horizontalHeader()->setVisible(false); + m_sourcesView->setAlternatingRowColors(false); + m_sourcesView->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_sourcesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_sourcesView->setWordWrap(false); + m_sourcesView->setIconSize(QSize(16, 12)); + // Ensure long paths truncate from the left: "...tail/of/the/path" + m_sourcesView->setTextElideMode(Qt::ElideLeft); + // Hide GUID column, keep only the Path column visible + m_sourcesView->setColumnHidden(WarpSourcesModel::GuidCol, true); + // Also hide boolean columns; their state is shown as an icon next to the path + m_sourcesView->setColumnHidden(WarpSourcesModel::WritableCol, true); + m_sourcesView->setColumnHidden(WarpSourcesModel::UncommittedCol, true); + // Ensure the remaining (Path) column fills the width + m_sourcesView->horizontalHeader()->setSectionResizeMode(WarpSourcesModel::PathCol, QHeaderView::Stretch); + + // Per-item context menu + m_sourcesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_sourcesView, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { + QMenu menu(m_sourcesView); + const QModelIndex index = m_sourcesView->indexAt(pos); + + if (!index.isValid()) + { + QAction *actAdd = menu.addAction(tr("Add Source")); + QAction *chosen = menu.exec(m_sourcesView->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == actAdd) + { + std::string sourceName; + if (!BinaryNinja::GetTextLineInput(sourceName, "Source name:", "Add Source")) + return; + if (const auto sourceId = m_container->AddSource(sourceName); !sourceId.has_value()) + { + BinaryNinja::LogAlertF("Failed to add source: {}", sourceName); + return; + } + m_sourcesModel->reload(); + } + } else + { + m_sourcesView->setCurrentIndex(index.sibling(index.row(), WarpSourcesModel::PathCol)); + + const int row = index.row(); + const QModelIndex pathIdx = m_sourcesModel->index(row, WarpSourcesModel::PathCol); + const QModelIndex guidIdx = m_sourcesModel->index(row, WarpSourcesModel::GuidCol); + const QString path = m_sourcesModel->data(pathIdx, Qt::DisplayRole).toString(); + const QFileInfo fi(path); + + const QString guid = m_sourcesModel->data(guidIdx, Qt::DisplayRole).toString(); + + QAction *actReveal = menu.addAction(tr("Reveal in File Browser")); + actReveal->setEnabled(fi.exists()); + QAction *actCopyPath = menu.addAction(tr("Copy Path")); + QAction *actCopyGuid = menu.addAction(tr("Copy GUID")); + + QAction *chosen = menu.exec(m_sourcesView->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == actCopyPath) + QGuiApplication::clipboard()->setText(path); + else if (chosen == actCopyGuid) + QGuiApplication::clipboard()->setText(guid); + else if (chosen == actReveal) + QDesktopServices::openUrl(QUrl::fromLocalFile(fi.absoluteFilePath())); + } + }); + + + sourcesLayout->addWidget(m_sourcesView); + m_tabs->addTab(m_sourcesPage, tr("Sources")); + + // Search tab + m_searchTab = new WarpSearchWidget(m_container, this); + m_tabs->addTab(m_searchTab, tr("Search")); + + // Periodic refresh timer for the Sources view + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(5000); + connect(m_refreshTimer, &QTimer::timeout, this, [this]() { + // Only refresh if the widget and the Sources page are actually visible + if (!this->isVisible() || !m_sourcesPage || !m_sourcesPage->isVisible()) + return; + + // Preserve selection by GUID across reloads + QString currentGuid; + if (const QModelIndex currentIdx = m_sourcesView->currentIndex(); currentIdx.isValid()) + { + const int row = currentIdx.row(); + const QModelIndex guidIdx = m_sourcesModel->index(row, WarpSourcesModel::GuidCol); + currentGuid = m_sourcesModel->data(guidIdx, Qt::DisplayRole).toString(); + } + + m_sourcesModel->reload(); + + if (!currentGuid.isEmpty()) + { + for (int r = 0; r < m_sourcesModel->rowCount(); ++r) + { + const QModelIndex gIdx = m_sourcesModel->index(r, WarpSourcesModel::GuidCol); + if (m_sourcesModel->data(gIdx, Qt::DisplayRole).toString() == currentGuid) + { + m_sourcesView->setCurrentIndex(m_sourcesModel->index(r, WarpSourcesModel::PathCol)); + break; + } + } + } + }); + m_refreshTimer->start(); + + // Optional: force a refresh when switching back to the Sources tab + connect(m_tabs, &QTabWidget::currentChanged, this, [this](const int idx) { + QWidget *w = m_tabs->widget(idx); + if (w == m_sourcesPage) + m_sourcesModel->reload(); + }); +} + +WarpContainersPane::WarpContainersPane(QWidget *parent) : QWidget(parent) +{ + auto *splitter = new QSplitter(Qt::Vertical, this); + splitter->setContentsMargins(0, 0, 0, 0); + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(0); + mainLayout->addWidget(splitter); + auto newPalette = palette(); + newPalette.setColor(QPalette::Window, getThemeColor(SidebarWidgetBackgroundColor)); + setAutoFillBackground(true); + setPalette(newPalette); + + // List on top + m_list = new QListWidget(splitter); + m_list->setSelectionMode(QAbstractItemView::SingleSelection); + m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_list->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_list->setUniformItemSizes(true); + + // Make names larger and show end of long strings (elide at the start) + { + QFont f = m_list->font(); + f.setPointSizeF(f.pointSizeF() + 2.0); // bump size + m_list->setFont(f); + m_list->setTextElideMode(Qt::ElideLeft); + } + + // Container view (tabs) below + m_stack = new QStackedWidget(splitter); + m_stack->setContentsMargins(0, 0, 0, 0); + + splitter->setStretchFactor(0, 0); // list: minimal growth + splitter->setStretchFactor(1, 1); // stack: takes remaining space + splitter->setCollapsible(0, false); + splitter->setCollapsible(1, false); + + populate(); + + connect(m_list, &QListWidget::currentRowChanged, this, [this](int row) { + if (row >= 0 && row < m_stack->count()) + m_stack->setCurrentIndex(row); + }); + + // Select the first container if available + if (m_list->count() > 0) + { + m_list->setCurrentRow(0); + } +} diff --git a/plugins/warp/ui/containers.h b/plugins/warp/ui/containers.h new file mode 100644 index 00000000..80ffa12f --- /dev/null +++ b/plugins/warp/ui/containers.h @@ -0,0 +1,175 @@ +#pragma once + +#include <QWidget> +#include <optional> +#include <QClipboard> +#include <QDesktopServices> +#include <QInputDialog> +#include <QListWidget> +#include "shared/search.h" + +#include "theme.h" +#include "warp.h" +#include "../../../../ui/mainwindow.h" + +class WarpSourcesModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Columns : int + { + GuidCol = 0, + PathCol, + WritableCol, + UncommittedCol, + ColumnCount + }; + + explicit WarpSourcesModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + void setContainer(Warp::Ref<Warp::Container> container) + { + m_container = std::move(container); + reload(); + } + + void reload() + { + // Fetch synchronously (can be adapted to async if needed) + beginResetModel(); + m_rows.clear(); + for (const auto &src: m_container->GetSources()) + { + QString guid = QString::fromStdString(src.ToString()); + QString path = QString::fromStdString(m_container->SourcePath(src).value_or(std::string{})); + bool writable = m_container->IsSourceWritable(src); + bool uncommitted = m_container->IsSourceUncommitted(src); + m_rows.push_back({guid, path, writable, uncommitted}); + } + endResetModel(); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + if (parent.isValid()) return 0; + return static_cast<int>(m_rows.size()); + } + + int columnCount(const QModelIndex &parent = QModelIndex()) const override + { + Q_UNUSED(parent); + return ColumnCount; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override + { + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) + { + switch (section) + { + case GuidCol: return "Source GUID"; + case PathCol: return "Path"; + case WritableCol: return "Writable"; + case UncommittedCol: return "Uncommitted"; + default: return {}; + } + } + return {}; + } + +private: + struct Row + { + QString guid; + QString path; + bool writable; + bool uncommitted; + }; + + std::vector<Row> m_rows; + Warp::Ref<Warp::Container> m_container; +}; + +class WarpContainerWidget : public QWidget +{ + Q_OBJECT + +public: + explicit WarpContainerWidget(Warp::Ref<Warp::Container> container, QWidget *parent = nullptr); + +private: + Warp::Ref<Warp::Container> m_container; + + QTabWidget *m_tabs = nullptr; + + // Sources + QTableView *m_sourcesView = nullptr; + WarpSourcesModel *m_sourcesModel = nullptr; + QWidget* m_sourcesPage = nullptr; + QTimer* m_refreshTimer = nullptr; + + // Search + WarpSearchWidget *m_searchTab = nullptr; +}; + +class WarpContainersPane : public QWidget +{ + Q_OBJECT + +public: + explicit WarpContainersPane(QWidget *parent = nullptr); + + void refresh() + { + // Clear and repopulate from current container list + m_list->clear(); + while (m_stack->count() > 0) + { + QWidget *w = m_stack->widget(0); + m_stack->removeWidget(w); + delete w; + } + m_containers.clear(); + populate(); + if (m_list->count() > 0) + m_list->setCurrentRow(0); + } + +private: + void populate() + { + // Retrieve all available containers + const auto all = Warp::Container::All(); + m_containers = all; // copy vector<Ref<Container>> + + for (const auto &c: m_containers) + { + const QString name = QString::fromStdString(c->GetName()); + auto *item = new QListWidgetItem(name, m_list); + item->setSizeHint(QSize(item->sizeHint().width(), itemHeightPx())); + auto *widget = new WarpContainerWidget(c, m_stack); + m_stack->addWidget(widget); + } + + // Visual style: behave like a vertical tab bar + // m_list->setFrameShape(QFrame::NoFrame); + // m_list->setSpacing(0); + } + + static int itemHeightPx() + { + // A reasonable, readable height per entry + return 28; + } + +private: + QListWidget *m_list = nullptr; + QStackedWidget *m_stack = nullptr; + std::vector<Warp::Ref<Warp::Container> > m_containers; +}; diff --git a/plugins/warp/ui/matches.cpp b/plugins/warp/ui/matches.cpp index 7e22e224..2345a7ea 100644 --- a/plugins/warp/ui/matches.cpp +++ b/plugins/warp/ui/matches.cpp @@ -11,12 +11,12 @@ #include "warp.h" #include "shared/misc.h" -WarpCurrentFunctionWidget::WarpCurrentFunctionWidget(FunctionRef current) +WarpCurrentFunctionWidget::WarpCurrentFunctionWidget() { - // NOTE: Might be nullptr if the no selected function. - m_current = current; + // We must explicitly support no current function. + m_current = nullptr; - m_logger = new BinaryNinja::Logger("WARP"); + m_logger = new BinaryNinja::Logger("WARP UI"); // Create the QT stuff QGridLayout *layout = new QGridLayout(this); @@ -107,6 +107,18 @@ WarpCurrentFunctionWidget::WarpCurrentFunctionWidget(FunctionRef current) }); } +void WarpCurrentFunctionWidget::SetFetcher(std::shared_ptr<WarpFetcher> fetcher) +{ + m_fetcher = fetcher; + // TODO: We need to remove the completion callback from the previously set fetcher. + m_fetcher->AddCompletionCallback([this]() { + // TODO: This is a little bit underspecified, we may end up updating more than strictly necessary. + // Once this function has been fetched, we need to update the matches for this widget. + UpdateMatches(); + return KeepCallback; + }); +} + void WarpCurrentFunctionWidget::SetCurrentFunction(FunctionRef current) { if (m_current == current) @@ -114,21 +126,18 @@ void WarpCurrentFunctionWidget::SetCurrentFunction(FunctionRef current) m_current = current; m_infoWidget->SetAnalysisFunction(m_current); - if (current) + // If we have a fetcher we should also let it know to try and fetch the possible functions from the containers. + if (current && m_fetcher) { - // Add the function to the processing list only if we have no already done so. - // If a user goes to a function, then navigates away, they do not want to - // have us try and send a network request! + m_fetcher->AddPendingFunction(current); + + // TODO: Automatically fetch the function, I need to figure out how to make this debounce correctly so that all requests are processed in line. + if (!m_fetcher->m_requestInProgress.exchange(true)) { - std::lock_guard<std::mutex> lock(m_requestMutex); - uint64_t funcStart = current->GetStart(); - if (m_processedFunctions.find(funcStart) == m_processedFunctions.end()) { - m_pendingRequests.push_back(current); - } - } - if (!m_requestInProgress.exchange(true)) { BinaryNinja::WorkerPriorityEnqueue([this]() { - ProcessPendingFetchRequests(); + BinaryNinja::Ref bgTask = new BinaryNinja::BackgroundTask("Fetching WARP Functions...", true); + m_fetcher->FetchPendingFunctions(); + bgTask->Finish(); }); } } @@ -185,51 +194,3 @@ void WarpCurrentFunctionWidget::UpdateMatches() m_tableWidget->SetFunctions(matches); } - -void WarpCurrentFunctionWidget::ProcessPendingFetchRequests() -{ - std::vector<FunctionRef> requests; - { - std::lock_guard<std::mutex> lock(m_requestMutex); - requests = std::move(m_pendingRequests); - m_pendingRequests.clear(); - } - - if (requests.empty()) { - m_requestInProgress = false; - return; - } - - auto start_time = std::chrono::high_resolution_clock::now(); - - std::vector<Warp::FunctionGUID> guids; - Warp::Ref<Warp::Target> target; - for (const auto& func : requests) { - // TODO: Need to send multiple requests if there is multiple targets. - if (!target) - target = Warp::Target::FromPlatform(*func->GetPlatform()); - if (const auto guid = Warp::GetAnalysisFunctionGUID(*func); guid.has_value()) - guids.push_back(guid.value()); - } - - // Actually fetch the data! - if (!guids.empty()) - for (const auto &container: Warp::Container::All()) - container->FetchFunctions(*target, guids); - - { - std::lock_guard<std::mutex> lock(m_requestMutex); - for (const auto& func : requests) { - m_processedFunctions.insert(func->GetStart()); - } - } - - // TODO: Update the matches, make sure there was stuff added first lol. - // TODO: UpdateMatches(); - - const auto end_time = std::chrono::high_resolution_clock::now(); - const std::chrono::duration<double> elapsed_time = end_time - start_time; - m_logger->LogDebug("ProcessPendingRequests took %f seconds", elapsed_time.count()); - - m_requestInProgress = false; -} diff --git a/plugins/warp/ui/matches.h b/plugins/warp/ui/matches.h index 251dcb89..88926add 100644 --- a/plugins/warp/ui/matches.h +++ b/plugins/warp/ui/matches.h @@ -4,6 +4,7 @@ #include "filter.h" #include "render.h" +#include "shared/fetcher.h" #include "shared/function.h" class WarpCurrentFunctionWidget : public QWidget @@ -18,21 +19,18 @@ class WarpCurrentFunctionWidget : public QWidget LoggerRef m_logger; - std::mutex m_requestMutex; - std::vector<FunctionRef> m_pendingRequests; - std::atomic<bool> m_requestInProgress {false}; - std::unordered_set<uint64_t> m_processedFunctions; + std::shared_ptr<WarpFetcher> m_fetcher; public: - explicit WarpCurrentFunctionWidget(FunctionRef current); + explicit WarpCurrentFunctionWidget(); ~WarpCurrentFunctionWidget() override = default; + void SetFetcher(std::shared_ptr<WarpFetcher> fetcher); + void SetCurrentFunction(FunctionRef current); FunctionRef GetCurrentFunction() { return m_current; }; void UpdateMatches(); - - void ProcessPendingFetchRequests(); }; diff --git a/plugins/warp/ui/plugin.cpp b/plugins/warp/ui/plugin.cpp index dccea1f1..388fea3c 100644 --- a/plugins/warp/ui/plugin.cpp +++ b/plugins/warp/ui/plugin.cpp @@ -6,6 +6,7 @@ #include "matches.h" #include "symbollist.h" #include "viewframe.h" +#include "shared/fetchdialog.h" using namespace BinaryNinja; @@ -32,11 +33,35 @@ Ref<BackgroundTask> GetMatcherTask() return matcherTask; } +void ShowNetworkNotice() +{ + // By default, network access is disabled for WARP, this function will show the user a notice to enable it and restart. + const auto settings = Settings::Instance(); + const bool networkNoticeShown = QSettings().value("warp/NetworkNoticeShown", false).toBool(); + QSettings().setValue("warp/NetworkNoticeShown", true); + if (!networkNoticeShown && settings->Contains("network.enableWARP") && !settings->Get<bool>("network.enableWARP")) + { + const bool enable = ShowMessageBox("Enable WARP Network Access?", + "Network access is disabled by default. Enable WARP network features now?\n\n" + "You can change this later in Settings.", + YesNoButtonSet, InformationIcon) == YesButton; + settings->Set("network.enableWARP", enable); + // TODO: Add a notifyRestartRequired call here + if (enable) + ShowMessageBox("WARP Network Enabled", "Please restart Binary Ninja to allow WARP to make requests to the server.", OKButtonSet, InformationIcon); + else + ShowMessageBox("WARP Network Disabled", "WARP network access will remain disabled. You can enable it later from Settings.", OKButtonSet, InformationIcon); + } +} + WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP"), m_data(data) { - m_logger = LogRegistry::CreateLogger("WARPUI"); + m_logger = LogRegistry::CreateLogger("WARP UI"); m_currentFrame = nullptr; + // If not already shown, opening the sidebar will give notice. + ShowNetworkNotice(); + m_headerWidget = new QWidget(); QHBoxLayout *headerLayout = new QHBoxLayout(); headerLayout->setContentsMargins(0, 0, 0, 0); @@ -46,20 +71,22 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") headerToolbar->setContentsMargins(0, 0, 0, 0); headerToolbar->setIconSize(QSize(20, 20)); - static auto matcherStopIcon = GetColoredIcon(":/icons/images/stop.png", getThemeColor(RedStandardHighlightColor)); - static auto matcherStartIcon = GetColoredIcon(":/icons/images/start.png", - getThemeColor(GreenStandardHighlightColor)); - m_matcherAction = headerToolbar->addAction(matcherStartIcon, "Run Matcher", [this]() { + auto fetchIcon = GetColoredIcon(":/icons/images/arrow-pull.png", getThemeColor(BlueStandardHighlightColor)); + auto fetchAction = headerToolbar->addAction(fetchIcon, "Fetch data from WARP containers", [this]() { UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); - if (Ref<BackgroundTask> matcherTask = GetMatcherTask()) - matcherTask->Cancel(); - else if (!isMatcherRunning) - { - handler->executeAction("WARP\\Run Matcher"); - setMatcherActionIcon(true); - } + handler->executeAction("WARP\\Fetch"); }); - m_matcherAction->setToolTip("Run the matcher on all functions"); + fetchAction->setToolTip("Fetch data from WARP containers"); + + auto commitIcon = GetColoredIcon(":/icons/images/arrow-push.png", getThemeColor(BlueStandardHighlightColor)); + auto commitAction = headerToolbar->addAction(commitIcon, "Commit a WARP file to a source", [this]() { + UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); + handler->executeAction("WARP\\Commit File"); + }); + commitAction->setToolTip("Commit a WARP file to a source"); + + // We want to make it clear that the container actions for fetching and pushing are seperate. + headerToolbar->addSeparator(); auto loadIcon = GetColoredIcon(":/icons/images/file-add.png", getThemeColor(BlueStandardHighlightColor)); auto loadAction = headerToolbar->addAction(loadIcon, "Load Signature File", [this]() { @@ -75,21 +102,36 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") }); saveAction->setToolTip("Save data to a signature file"); + headerToolbar->addSeparator(); + + static auto matcherStopIcon = GetColoredIcon(":/icons/images/stop.png", getThemeColor(RedStandardHighlightColor)); + static auto matcherStartIcon = GetColoredIcon(":/icons/images/start.png", + getThemeColor(GreenStandardHighlightColor)); + m_matcherAction = headerToolbar->addAction(matcherStartIcon, "Run Matcher", [this]() { + UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); + if (Ref<BackgroundTask> matcherTask = GetMatcherTask()) + matcherTask->Cancel(); + else if (!isMatcherRunning) + { + handler->executeAction("WARP\\Run Matcher"); + setMatcherActionIcon(true); + } + }); + m_matcherAction->setToolTip("Run the matcher on all functions"); + auto refreshIcon = GetColoredIcon(":/icons/images/refresh.png", getThemeColor(BlueStandardHighlightColor)); auto refreshAction = headerToolbar->addAction(refreshIcon, "Refresh the view data", [this]() { Update(); }); refreshAction->setToolTip("Refresh the sidebar data"); - // TODO: Add action for pushing to network sources. - // Push the toolbar to the right using a stretch space. headerLayout->addStretch(); headerLayout->addWidget(headerToolbar, 0); m_headerWidget->setLayout(headerLayout); QFrame *currentFunctionFrame = new QFrame(this); - m_currentFunctionWidget = new WarpCurrentFunctionWidget(nullptr); + m_currentFunctionWidget = new WarpCurrentFunctionWidget(); QVBoxLayout *currentFunctionLayout = new QVBoxLayout(); currentFunctionLayout->setContentsMargins(0, 0, 0, 0); currentFunctionLayout->setSpacing(0); @@ -104,6 +146,14 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") matchedLayout->addWidget(m_matchedWidget); matchedFrame->setLayout(matchedLayout); + QFrame *containerFrame = new QFrame(this); + m_containerWidget = new WarpContainersPane(); + QVBoxLayout *containerLayout = new QVBoxLayout(); + containerLayout->setContentsMargins(0, 0, 0, 0); + containerLayout->setSpacing(0); + containerLayout->addWidget(m_containerWidget); + containerFrame->setLayout(containerLayout); + QVBoxLayout *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); @@ -111,6 +161,7 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") auto tabWidget = new QTabWidget(this); tabWidget->addTab(currentFunctionFrame, "Current Function"); tabWidget->addTab(matchedFrame, "Matched Functions"); + tabWidget->addTab(containerFrame, "Containers"); m_analysisEvent = new AnalysisCompletionEvent(m_data, [this]() { ExecuteOnMainThread([this]() { @@ -120,6 +171,9 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") layout->addWidget(tabWidget); this->setLayout(layout); + + // NOTE: This fetcher is shared with the fetch dialog that is constructed on initialization of this plugin. + m_currentFunctionWidget->SetFetcher(WarpFetcher::Global()); } WarpSidebarWidget::~WarpSidebarWidget() @@ -133,7 +187,9 @@ void WarpSidebarWidget::focus() void WarpSidebarWidget::Update() { + m_currentFunctionWidget->UpdateMatches(); m_matchedWidget->Update(); + // TODO: Obviously this probably should not be called here. setMatcherActionIcon(false); } @@ -180,6 +236,7 @@ void WarpSidebarWidget::notifyViewLocationChanged(View *view, const ViewLocation WarpSidebarWidgetType::WarpSidebarWidgetType() : SidebarWidgetType(QImage(":/icons/images/warp.png"), "WARP") { + } @@ -194,6 +251,7 @@ BINARYNINJAPLUGIN void CorePluginDependencies() BINARYNINJAPLUGIN bool UIPluginInit() { + RegisterWarpFetchFunctionsCommand(); Sidebar::addSidebarWidgetType(new WarpSidebarWidgetType()); return true; } diff --git a/plugins/warp/ui/plugin.h b/plugins/warp/ui/plugin.h index 18525218..56b31711 100644 --- a/plugins/warp/ui/plugin.h +++ b/plugins/warp/ui/plugin.h @@ -4,6 +4,7 @@ #include "matches.h" #include "sidebar.h" #include "sidebarwidget.h" +#include "containers.h" class WarpSidebarWidget : public SidebarWidget { @@ -19,6 +20,7 @@ class WarpSidebarWidget : public SidebarWidget WarpCurrentFunctionWidget *m_currentFunctionWidget; WarpMatchedWidget *m_matchedWidget; + WarpContainersPane *m_containerWidget; public: explicit WarpSidebarWidget(BinaryViewRef data); diff --git a/plugins/warp/ui/shared/fetchdialog.cpp b/plugins/warp/ui/shared/fetchdialog.cpp new file mode 100644 index 00000000..0230ad51 --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.cpp @@ -0,0 +1,227 @@ +#include "fetchdialog.h" + +#include <QDialogButtonBox> +#include <QFormLayout> +#include <QInputDialog> +#include <QLabel> + +#include "action.h" +#include "fetcher.h" + +using namespace BinaryNinja; + +static void AddListItem(QListWidget *list, const QString &value) +{ + if (value.trimmed().isEmpty()) + return; + // Avoid duplicates + for (int i = 0; i < list->count(); ++i) + if (list->item(i)->text().compare(value, Qt::CaseInsensitive) == 0) + return; + list->addItem(value.trimmed()); +} + +WarpFetchDialog::WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetcher, + QWidget *parent) + : QDialog(parent), m_fetchProcessor(std::move(fetcher)), m_bv(std::move(bv)) +{ + setWindowTitle("Fetch WARP Functions"); + + auto form = new QFormLayout(); + m_containerCombo = new QComboBox(this); + populateContainers(); + m_containerCombo->addItem("All Containers"); // index 0 for "all" + for (const auto &c: m_containers) + m_containerCombo->addItem(QString::fromStdString(c->GetName())); + + // TODO: Need to add tooltip to explain that a source must have atleast one of these tags to be considered. + + // Tags editor + m_tagsList = new QListWidget(this); + m_addTagBtn = new QPushButton("Add", this); + m_removeTagBtn = new QPushButton("Remove", this); + auto tagBtnRow = new QHBoxLayout(); + tagBtnRow->addWidget(m_addTagBtn); + tagBtnRow->addWidget(m_removeTagBtn); + auto tagCol = new QVBoxLayout(); + tagCol->addWidget(m_tagsList); + tagCol->addLayout(tagBtnRow); + auto tagWrapper = new QWidget(this); + tagWrapper->setLayout(tagCol); + + // Make tags list compact with a fixed maximum height and no vertical expansion + m_tagsList->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_tagsList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_tagsList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_tagsList->setMaximumHeight(120); + m_tagsList->setToolTip("A source must have atleast ONE of these tags to be considered"); + + // Defaults from processor tags + for (const auto &t: m_fetchProcessor->GetTags()) + AddListItem(m_tagsList, QString::fromStdString(t)); + + // Batch size and matcher checkbox + m_batchSize = new QSpinBox(this); + m_batchSize->setRange(10, 1000); + m_batchSize->setValue(100); + m_batchSize->setToolTip("Number of functions to fetch in each batch"); + + m_rerunMatcher = new QCheckBox("Re-run matcher after fetch", this); + m_rerunMatcher->setChecked(true); + + m_clearProcessed = new QCheckBox("Refetch all functions", this); + m_clearProcessed->setToolTip("Clears the processed cache before fetching again, this will refetch all functions in the view"); + m_clearProcessed->setChecked(false); + + form->addRow(new QLabel("Container: "), m_containerCombo); + // TODO: Need to plumb this through to the fetcher, and also likely have a blacklisted or whitelist mode for this dialog. + // TODO: Alos wan to prefill the list of sources from the view/global settings. + // form->addRow(new QLabel("Allowed Sources: "), srcWrapper); + form->addRow(new QLabel("Allowed Tags: "), tagWrapper); + form->addRow(new QLabel("Batch Size: "), m_batchSize); + form->addRow(m_rerunMatcher); + form->addRow(m_clearProcessed); + + auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &WarpFetchDialog::onAccept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto root = new QVBoxLayout(this); + root->addLayout(form); + root->addWidget(buttons); + setLayout(root); + + // Wire buttons + connect(m_addTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onAddTag); + connect(m_removeTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onRemoveTag); +} + +void WarpFetchDialog::populateContainers() +{ + m_containers = Warp::Container::All(); +} + +void WarpFetchDialog::onAddTag() +{ + bool ok = false; + const auto text = QInputDialog::getText(this, "Add Tag", "Tag:", QLineEdit::Normal, {}, &ok); + if (ok) + AddListItem(m_tagsList, text); +} + +void WarpFetchDialog::onRemoveTag() +{ + for (auto *item: m_tagsList->selectedItems()) + delete item; +} + +std::vector<Warp::SourceTag> WarpFetchDialog::collectTags() const +{ + std::vector<Warp::SourceTag> out; + out.reserve(m_tagsList->count()); + for (int i = 0; i < m_tagsList->count(); ++i) + out.emplace_back(m_tagsList->item(i)->text().trimmed().toStdString()); + return out; +} + +void WarpFetchDialog::onAccept() +{ + const int idx = m_containerCombo->currentIndex(); + std::optional<size_t> containerIndex; + if (idx > 0) // 0 == All Containers + containerIndex = static_cast<size_t>(idx - 1); + + auto tags = collectTags(); + const auto batch = static_cast<size_t>(m_batchSize->value()); + const bool rerun = m_rerunMatcher->isChecked(); + + // Persist tags to the shared processor for consistency across navigation + m_fetchProcessor->SetTags(tags); + + if (m_clearProcessed->isChecked()) + m_fetchProcessor->ClearProcessed(); + + // Execute the network fetch in batches + runBatchedFetch(containerIndex, tags, batch, rerun); + + accept(); +} + +void WarpFetchDialog::runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher) +{ + if (!m_bv) + return; + // Collect functions in the view and enqueue them to the shared fetcher + std::vector<Ref<Function>> funcs = m_bv->GetAnalysisFunctionList(); + if (funcs.empty()) + return; + const size_t totalFuncs = funcs.size(); + const size_t totalBatches = (totalFuncs + batchSize - 1) / batchSize; + + // Create a background task to show progress in the UI + Ref<BackgroundTask> task = new BackgroundTask("Fetching WARP functions (0 / " + std::to_string(totalBatches) + ")", false); + + auto fetcher = m_fetchProcessor; + auto bv = m_bv; + + // TODO: Too many captures in this thing lol. + WorkerInteractiveEnqueue([fetcher, bv, funcs = std::move(funcs), batchSize, rerunMatcher, task]() mutable { + size_t processed = 0; + size_t batchIndex = 0; + + while (processed < funcs.size()) + { + const size_t remaining = funcs.size() - processed; + const size_t thisBatchCount = std::min(batchSize, remaining); + + for (size_t i = 0; i < thisBatchCount; ++i) + fetcher->AddPendingFunction(funcs[processed + i]); + + fetcher->FetchPendingFunctions(); + + ++batchIndex; + processed += thisBatchCount; + + task->SetProgressText("Fetching WARP functions (" + std::to_string(batchIndex) + " / " + std::to_string((funcs.size() + batchSize - 1) / batchSize) + ")"); + } + + task->Finish(); + // TODO: Print how long it took? + Logger("WARP Fetcher").LogInfo("Finished fetching WARP functions..."); + + if (rerunMatcher && bv) + Warp::RunMatcher(*bv); + }); +} + +void RegisterWarpFetchFunctionsCommand() +{ + // Register a UI action and bind it globally. Add it to the Tools menu. + const QString actionName = "WARP\\Fetch"; + + // TODO: Because we register this in every widget this will happen, this is bad behavior! + if (!UIAction::isActionRegistered(actionName)) + UIAction::registerAction(actionName); + + UIActionHandler::globalActions()->bindAction( + actionName, + UIAction( + [](const UIActionContext &context) { + if (const BinaryViewRef bv = context.binaryView; bv) + { + WarpFetchDialog dlg(bv, WarpFetcher::Global(), nullptr); + dlg.exec(); + } + }, + [](const UIActionContext &context) { + return context.binaryView != nullptr; + } + ) + ); + + Menu::mainMenu("Plugins")->addAction(actionName, "Plugins"); +} diff --git a/plugins/warp/ui/shared/fetchdialog.h b/plugins/warp/ui/shared/fetchdialog.h new file mode 100644 index 00000000..72591a4c --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.h @@ -0,0 +1,56 @@ +#pragma once + +#include <QDialog> +#include <QComboBox> +#include <QListWidget> +#include <QSpinBox> +#include <QCheckBox> + +#include "uicontext.h" +#include "viewframe.h" +#include "warp.h" +#include "fetcher.h" + +class WarpFetchDialog : public QDialog +{ + Q_OBJECT + + QComboBox *m_containerCombo; + + QListWidget *m_tagsList; + QPushButton *m_addTagBtn; + QPushButton *m_removeTagBtn; + + QSpinBox *m_batchSize; + QCheckBox *m_rerunMatcher; + QCheckBox *m_clearProcessed; + + std::vector<Warp::Ref<Warp::Container> > m_containers; + + std::shared_ptr<WarpFetcher> m_fetchProcessor; + BinaryViewRef m_bv; + +public: + explicit WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetchProcessor, + QWidget *parent = nullptr); + +private slots: + void onAddTag(); + + void onRemoveTag(); + + void onAccept(); + +private: + void populateContainers(); + + std::vector<Warp::SourceTag> collectTags() const; + + void runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher); +}; + +void RegisterWarpFetchFunctionsCommand(); diff --git a/plugins/warp/ui/shared/fetcher.cpp b/plugins/warp/ui/shared/fetcher.cpp new file mode 100644 index 00000000..767932e3 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.cpp @@ -0,0 +1,109 @@ +#include "fetcher.h" + +#include <QSettings> + +WarpFetcher::WarpFetcher() +{ + m_logger = new BinaryNinja::Logger("WARP Fetcher"); + QSettings qtSettings; + const QString key = "warp/allowedTags"; + + QStringList tags = qtSettings.value(key).toStringList(); + if (tags.isEmpty()) { + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue(key, tags); + qtSettings.sync(); + } + + std::vector<Warp::SourceTag> initialTags; + initialTags.reserve(tags.size()); + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + + SetTags(initialTags); +} + +void WarpFetcher::AddPendingFunction(const FunctionRef &func) +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value() || m_processedGuids.contains(*guid)) + return; + m_pendingRequests.push_back(func); +} + +std::vector<FunctionRef> WarpFetcher::FlushPendingFunctions() +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + std::vector<FunctionRef> requests = std::move(m_pendingRequests); + m_pendingRequests.clear(); + return requests; +} + +void WarpFetcher::ExecuteCompletionCallback() +{ + BinaryNinja::ExecuteOnMainThread([this]() { + // TODO: Holding the mutex here is dangerous! + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.erase( + std::ranges::remove_if(m_completionCallbacks, + [](const auto &cb) { return cb() != RemoveCallback; }).begin(), + m_completionCallbacks.end()); + }); +} + +std::shared_ptr<WarpFetcher> WarpFetcher::Global() +{ + static auto global = std::make_shared<WarpFetcher>(); + return global; +} + +void WarpFetcher::FetchPendingFunctions() +{ + m_requestInProgress = true; + const auto requests = FlushPendingFunctions(); + if (requests.empty()) + { + m_logger->LogDebug("No pending requests to fetch... skipping"); + m_requestInProgress = false; + return; + } + + const auto start_time = std::chrono::high_resolution_clock::now(); + + // Because we must fetch for a single target we map the function guids to the associated platform to perform fetches for each. + std::map<PlatformRef, std::vector<Warp::FunctionGUID>> platformMappedGuids; + for (const auto &func: requests) + { + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value()) + continue; + auto platform = func->GetPlatform(); + platformMappedGuids[platform].push_back(guid.value()); + } + + const auto tags = GetTags(); + for (const auto &[platform, guids] : platformMappedGuids) + { + m_logger->LogDebugF("Fetching {} functions for platform {}", guids.size(), platform->GetName()); + auto target = Warp::Target::FromPlatform(*platform); + for (const auto &container: Warp::Container::All()) + container->FetchFunctions(*target, guids, tags); + + std::lock_guard<std::mutex> lock(m_requestMutex); + for (const auto &guid: guids) + m_processedGuids.insert(guid); + } + + m_requestInProgress = false; + ExecuteCompletionCallback(); + const auto end_time = std::chrono::high_resolution_clock::now(); + const std::chrono::duration<double> elapsed_time = end_time - start_time; + m_logger->LogDebug("Fetch batch took %f seconds", elapsed_time.count()); +} + +void WarpFetcher::ClearProcessed() +{ + m_logger->LogInfoF("Clearing {} processed functions from cache...", m_processedGuids.size()); + m_processedGuids.clear(); +} diff --git a/plugins/warp/ui/shared/fetcher.h b/plugins/warp/ui/shared/fetcher.h new file mode 100644 index 00000000..a7195ac4 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.h @@ -0,0 +1,85 @@ +#pragma once + +#include <atomic> +#include <mutex> +#include <unordered_set> +#include <vector> +#include <functional> +#include <QSettings> + +#include "warp.h" +#include "binaryninjaapi.h" +#include "uitypes.h" + +enum WarpFetchCompletionStatus +{ + KeepCallback, + RemoveCallback, +}; + +// Responsible for fetching data from the containers, to later be queried from the container interface. +class WarpFetcher +{ + LoggerRef m_logger; + + std::mutex m_requestMutex; + std::vector<FunctionRef> m_pendingRequests; + // TODO: Easy way to clear this if user wants to refetch. + std::unordered_set<Warp::FunctionGUID> m_processedGuids; + + // List of callbacks to call when done fetching data, assume that others are using this as well. + std::vector<std::function<WarpFetchCompletionStatus()> > m_completionCallbacks; + +public: + explicit WarpFetcher(); + + // The global fetcher instance, this is used for the fetch dialog and the sidebar. + static std::shared_ptr<WarpFetcher> Global(); + + std::atomic<bool> m_requestInProgress = false; + + // Set the allowed source tags, sources with none of these tags will not be fetched from. + void SetTags(const std::vector<Warp::SourceTag> &tags) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QStringList qtTags = {}; + for (const auto& t : tags) + qtTags.append(QString::fromStdString(t)); + QSettings().setValue("warp/allowedTags", qtTags); + } + + std::vector<Warp::SourceTag> GetTags() const + { + std::lock_guard<std::mutex> lock(const_cast<std::mutex &>(m_requestMutex)); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QSettings qtSettings; + QStringList tags = qtSettings.value("warp/allowedTags").toStringList(); + if (tags.isEmpty()) { + // The default tags to allow. + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue("warp/allowedTags", tags); + qtSettings.sync(); + } + std::vector<Warp::SourceTag> initialTags = {}; + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + return initialTags; + } + + void AddCompletionCallback(std::function<WarpFetchCompletionStatus()> cb) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.push_back(std::move(cb)); + } + + void AddPendingFunction(const FunctionRef &func); + + void FetchPendingFunctions(); + + void ClearProcessed(); +private: + std::vector<FunctionRef> FlushPendingFunctions(); + + void ExecuteCompletionCallback(); +}; diff --git a/plugins/warp/ui/shared/function.cpp b/plugins/warp/ui/shared/function.cpp index 88d27cd1..e1609e0b 100644 --- a/plugins/warp/ui/shared/function.cpp +++ b/plugins/warp/ui/shared/function.cpp @@ -14,35 +14,15 @@ WarpFunctionItem::WarpFunctionItem(Warp::Ref<Warp::Function> function, { m_function = function; - // TODO: This needs to be better. Symbol can be nullptr. BinaryNinja::Ref<BinaryNinja::Symbol> symbol = m_function->GetSymbol(*analysisFunction); std::string symbolName = symbol->GetShortName(); setText(QString::fromStdString(symbolName)); - BinaryNinja::InstructionTextToken nameToken = {255, TextToken, symbolName}; // Serialize the tokens to make it accessible via QModelIndex. // We will take these tokens and then user them in our custom item delegate. - TokenData tokenData = {}; - - // TODO: Make this not look like garbage - BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction); - if (type) - { - BinaryNinja::Ref<BinaryNinja::Platform> platform = analysisFunction->GetPlatform(); - std::vector<BinaryNinja::InstructionTextToken> beforeTokens = type->GetTokensBeforeName(platform); - std::vector<BinaryNinja::InstructionTextToken> afterTokens = type->GetTokensAfterName(platform); - - for (const auto &token: beforeTokens) - tokenData.tokens.emplace_back(token); - tokenData.tokens.emplace_back(255, TextToken, " "); - tokenData.tokens.emplace_back(nameToken); - for (const auto &token: afterTokens) - tokenData.tokens.emplace_back(token); - } else - { - tokenData.tokens.emplace_back(nameToken); - } - + TokenData tokenData = TokenData(symbolName); + if (BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction)) + tokenData = TokenData(*type, symbolName); setData(QVariant::fromValue(tokenData), Qt::UserRole); } diff --git a/plugins/warp/ui/shared/misc.cpp b/plugins/warp/ui/shared/misc.cpp index 71147079..bbe3ab24 100644 --- a/plugins/warp/ui/shared/misc.cpp +++ b/plugins/warp/ui/shared/misc.cpp @@ -8,6 +8,21 @@ #include "render.h" #include "theme.h" +TokenData::TokenData(const std::string &name) +{ + tokens.emplace_back(255, TextToken, name); +} + +TokenData::TokenData(const BinaryNinja::Type &type, const std::string& name) +{ + for (const auto &token: type.GetTokensBeforeName()) + tokens.emplace_back(token); + tokens.emplace_back(255, TextToken, " "); + tokens.emplace_back(255, TextToken, name); + for (const auto &token: type.GetTokensAfterName()) + tokens.emplace_back(token); +} + void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { painter->save(); @@ -23,7 +38,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt painter->translate(option.rect.topLeft()); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); renderContext.init(*painter); HighlightTokenState highlightState; renderContext.drawDisassemblyLine(*painter, 5, 5, {tokenData.tokens.begin(), tokenData.tokens.end()}, @@ -35,7 +50,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QSize TokenDataDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { auto tokenData = index.data(Qt::UserRole).value<TokenData>(); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); QFontMetrics fontMetrics = QFontMetrics(renderContext.getFont()); QString line = ""; for (const auto &token: tokenData.tokens) @@ -79,3 +94,47 @@ bool GenericTextFilterModel::lessThan(const QModelIndex &sourceLeft, const QMode auto rightData = sourceRight.data().toString(); return QString::localeAwareCompare(leftData, rightData) < 0; } + +ParsedQuery::ParsedQuery(const QString &rawQuery) +{ + query = rawQuery; + qualifiers = {}; + + // Regex capturing: key, and value (bare, quoted and unquoted) + static QRegularExpression re(R"((^|\s)([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(?:\"([^\"]*)\"|'([^']*)'|(\S+)))", + QRegularExpression::CaseInsensitiveOption); + + // Collect matches to remove later (from end to start to keep indices stable) + struct Span + { + qsizetype start; + qsizetype length; + QString key; + QString value; + }; + QVector<Span> spans; + + auto it = re.globalMatch(rawQuery); + while (it.hasNext()) + { + const auto m = it.next(); + const QString key = m.captured(2).toLower(); + // Value can be in group 3 (double quotes), 4 (single quotes), or 5 (bare) + QString val = m.captured(3); + if (val.isNull() || val.isEmpty()) val = m.captured(4); + if (val.isNull() || val.isEmpty()) val = m.captured(5); + spans.push_back(Span{m.capturedStart(0), m.capturedLength(0), key, val}); + } + + // Keep only the last value per key (last occurrence wins, do not accumulate) + for (const auto &s: spans) + qualifiers[s.key] = s.value; + + // Remove matched spans from the text (replace with a single space to preserve boundaries) + std::sort(spans.begin(), spans.end(), [](const Span &a, const Span &b) { return a.start > b.start; }); + for (const auto &s: spans) + query.replace(s.start, s.length, QStringLiteral(" ")); + + // Normalize whitespace + query = query.simplified(); +} diff --git a/plugins/warp/ui/shared/misc.h b/plugins/warp/ui/shared/misc.h index d2e6a1a8..35f640f5 100644 --- a/plugins/warp/ui/shared/misc.h +++ b/plugins/warp/ui/shared/misc.h @@ -5,9 +5,11 @@ #include <QStyledItemDelegate> #include <QTableView> #include <QVector> +#include <utility> #include "binaryninjaapi.h" #include "filter.h" +#include "warp.h" // Used to serialize into the item data for rendering with TokenDataDelegate. struct TokenData @@ -16,6 +18,10 @@ struct TokenData TokenData() = default; + TokenData(const std::string& name); + + TokenData(const BinaryNinja::Type &type, const std::string& name); + TokenData(const std::vector<BinaryNinja::InstructionTextToken> &tokens) { for (const auto &token: tokens) @@ -72,7 +78,7 @@ class GenericTextFilterModel : public QSortFilterProxyModel Q_OBJECT public: - GenericTextFilterModel(QObject *parent): QSortFilterProxyModel(parent) + GenericTextFilterModel(QObject *parent) : QSortFilterProxyModel(parent) { } @@ -82,3 +88,27 @@ public: bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override; }; + +// Used to parse qualifiers out of a user-supplied string (or "query") +struct ParsedQuery +{ + // The actual query, without the qualifiers like source:<uuid> + QString query; + // The qualifiers used to build other optional parts of the query. + QHash<QString, QString> qualifiers; + + ParsedQuery(QString query, const QHash<QString, QString> &qualifiers) + : query(std::move(query)), qualifiers(qualifiers) + { + } + + explicit ParsedQuery(const QString &rawQuery); + + [[nodiscard]] std::optional<QString> GetValue(const QString &key) const + { + const auto it = qualifiers.constFind(key); + if (it == qualifiers.constEnd() || it->isEmpty()) + return std::nullopt; + return it.value(); + } +}; diff --git a/plugins/warp/ui/shared/search.cpp b/plugins/warp/ui/shared/search.cpp new file mode 100644 index 00000000..1431e9b6 --- /dev/null +++ b/plugins/warp/ui/shared/search.cpp @@ -0,0 +1,301 @@ +#include "search.h" +#include "misc.h" +#include "../../../../../ui/mainwindow.h" + +QVariant WarpSearchModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto &it = m_items[index.row()]; + + // Provide tokenized type for functions via UserRole for TokenDataDelegate + if (role == Qt::UserRole && index.column() == DisplayCol) + { + if (it && it->GetKind() == WARPContainerSearchItemKindFunction) + if (auto itemType = it->GetType(nullptr)) + return QVariant::fromValue(TokenData(*itemType, it->GetName())); + return {}; + } + + + if (role == Qt::DisplayRole) + { + // TODO: We might want to run the demangler here on the name. + switch (index.column()) + { + case DisplayCol: return QString::fromStdString(it->GetName()); + case KindCol: + { + switch (it->GetKind()) + { + case WARPContainerSearchItemKindFunction: return "Function"; + case WARPContainerSearchItemKindType: return "Type"; + case WARPContainerSearchItemKindSource: return "Source"; + case WARPContainerSearchItemKindSymbol: return "Symbol"; + default: return {}; + } + } + case SourceCol: return QString::fromStdString(it->GetSource().ToString()); + default: return {}; + } + } + return {}; +} + +QVariant WarpSearchModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) + { + switch (section) + { + case DisplayCol: return "Item"; + case KindCol: return "Kind"; + case SourceCol: return "Source"; + default: return {}; + } + } + return {}; +} + +void WarpSearchDelegate::paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + // TODO: We actually still want the function icon, I think? So this should not early return and instead replace the name. + // If model provided TokenData (e.g., for function types), render via TokenDataDelegate + if (index.data(Qt::UserRole).canConvert<TokenData>()) + { + TokenDataDelegate(parent()).paint(p, option, index); + return; + } + + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + // We will draw the background/selection via style, and custom-draw icon (left) + text. + const QWidget *w = option.widget; + QStyle *style = w ? w->style() : QApplication::style(); + + // Let style draw the item background/selection etc., but not the default text + QString originalText = opt.text; + opt.text.clear(); + style->drawControl(QStyle::CE_ItemViewItem, &opt, p, w); + + // Retrieve text for DisplayCol + const QString &text = originalText; + + // Retrieve kind text from the hidden KindCol + const QModelIndex kindIndex = index.sibling(index.row(), WarpSearchModel::KindCol); + const QString kind = kindIndex.isValid() ? kindIndex.data(Qt::DisplayRole).toString() : QString(); + + // Map kind -> icon (emoji for simplicity; replace with QIcon/QPixmap if desired) + const QString icon = kindToIcon(kind); + + // Layout rects + const QRect r = opt.rect; + const int marginH = 8; + const int iconSide = 16; + const int spacing = 8; + + // Reserve space on the left for the icon if we have one + QRect iconRect = QRect(r.left() + marginH, r.center().y() - iconSide / 2, iconSide, iconSide); + QRect textRect = r.adjusted((icon.isEmpty() ? marginH : (marginH + iconSide + spacing)), 0, -marginH, 0); + + // Draw icon first (without clipping), then text with clipping + p->save(); + if (!icon.isEmpty()) + { + QFont iconFont = opt.font; + iconFont.setPointSizeF(iconFont.pointSizeF() + 2); + p->setFont(iconFont); + p->setPen( + opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(iconRect, Qt::AlignCenter, icon); + } + p->restore(); + + // Draw elided text, vertically centered (with clipping so long strings don't overflow) + p->save(); + p->setClipRect(textRect); + const QFontMetrics fm(opt.font); + const QString elided = fm.elidedText(text, Qt::ElideRight, textRect.width()); + p->setPen(opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, elided); + p->restore(); +} + +void WarpSearchRunner::fetchPage(std::size_t offset, std::size_t limit) +{ + const auto session = currentSession(); + auto container = m_container; + auto q = m_queryText; + auto src = m_source; + + // Runs search on Qt threadpool then moves the result to the main thread to update the UI. + using TaskResult = std::pair<std::optional<Warp::ContainerSearchResponse>, QString>; + QFutureWatcher<TaskResult> *watcher = new QFutureWatcher<TaskResult>(this); + const auto future = QtConcurrent::run([container, q, src, offset, limit]() -> TaskResult { + const Warp::ContainerSearchQuery query(q.toStdString(), offset, limit, src); + auto respOpt = container->Search(query); + // TODO: We may want to provide better errors in the future, e.g. BNWARPGetError + if (!respOpt.has_value()) + return {std::nullopt, QStringLiteral("No response from container")}; + return {std::move(respOpt), QString()}; + }); + connect(watcher, &QFutureWatcher<TaskResult>::finished, this, [this, watcher, session]() { + const TaskResult result = watcher->result(); + watcher->deleteLater(); + if (const auto &err = result.second; !err.isEmpty()) + emit pageError(session, err); + else if (const auto &resp = result.first; resp.has_value()) + emit pageReady(session, *resp); + }); + watcher->setFuture(future); +} + +WarpSearchWidget::WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent) : QWidget(parent) +{ + m_model = new WarpSearchModel(this); + m_runner = new WarpSearchRunner(container, this); + auto *layout = new QVBoxLayout(this); + m_query = new QLineEdit(this); + m_status = new QLabel(this); + m_view = new QTableView(this); + + // TODO: I am not necessarily a fan with how this looks. + m_query->setPlaceholderText("Search… (Optionally filter with source:<uuid>)"); + + // This is where we make the table not look like a table but instead a list. + m_view->setShowGrid(false); + m_view->verticalHeader()->setVisible(false); + m_view->horizontalHeader()->setVisible(false); + m_view->setSelectionBehavior(QAbstractItemView::SelectRows); + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setFocusPolicy(Qt::NoFocus); + + layout->addWidget(m_query); + layout->addWidget(m_status); + layout->addWidget(m_view); + + m_view->setModel(m_model); + + // Use a delegate to draw the DisplayCol text with an icon (optionally) aligned to the left. + m_view->setItemDelegateForColumn(WarpSearchModel::DisplayCol, new WarpSearchDelegate(m_view)); + + // Hide extra columns: only show the combined "Item" column; do not display source GUID. + m_view->setColumnHidden(WarpSearchModel::KindCol, true); + m_view->setColumnHidden(WarpSearchModel::SourceCol, true); + m_view->horizontalHeader()->setSectionResizeMode(WarpSearchModel::DisplayCol, QHeaderView::Stretch); + + // Debounce user input + m_debounce.setSingleShot(true); + m_debounce.setInterval(SEARCH_DEBOUNCE_MS); + connect(m_query, &QLineEdit::textChanged, this, [this](const QString &) { m_debounce.start(); }); + + connect(&m_debounce, &QTimer::timeout, this, [this]() { + const QString raw = m_query->text(); + + // Parse generic qualifiers and clean the free-text query + const auto parsed = ParsedQuery(raw); + + std::optional<Warp::Source> inlineSource = std::nullopt; + if (const auto val = parsed.GetValue("source"); val.has_value()) + inlineSource = Warp::Source::FromString(val.value().toStdString()); + const auto effectiveSource = inlineSource.has_value() ? inlineSource : m_source; + // TODO: Filter for tags and function guid. + + m_runner->startSession(parsed.query, effectiveSource); + m_model->beginNewSearch(SEARCH_PAGE_SIZE); + }); + + + // Infinite scroll trigger + connect(m_model, &WarpSearchModel::fetchMoreRequested, this, [this](std::size_t offset, std::size_t limit) { + m_status->setText(QStringLiteral("Loading… (%1/%2)").arg(m_model->currentCount()).arg(m_model->total())); + m_runner->fetchPage(offset, limit); + }); + + // Append results if session still valid + connect(m_runner, &WarpSearchRunner::pageReady, this, + [this](std::uint64_t, const Warp::ContainerSearchResponse &resp) { + m_model->appendResponse(resp); + m_status->setText( + QStringLiteral("Showing %1 of %2").arg(m_model->currentCount()).arg(m_model->total())); + }); + + // In cases like if the network container server goes down. + connect(m_runner, &WarpSearchRunner::pageError, this, + [this](std::uint64_t, const QString &msg) { + m_status->setText(QStringLiteral("Error: %1").arg(msg)); + }); + + // Add a context menu so that we can actually do actionable things with what we find. + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QTableView::customContextMenuRequested, this, [this](const QPoint &pos) { + const QModelIndex idx = m_view->indexAt(pos); + if (!idx.isValid()) + return; + + // TODO: Getting the current view here is really awful, but i dont care right now. + auto ctx = MainWindow::activeContext(); + auto view = ctx->getCurrentView(); + auto binaryView = view->getData(); + auto viewFrame = ctx->getCurrentViewFrame(); + auto viewLocation = viewFrame->getViewLocation(); + auto func = viewLocation.getFunction(); + + // Retrieve the current architecture from the current function or try the current view. + auto arch = binaryView->GetDefaultArchitecture(); + if (func) + arch = func->GetArchitecture(); + + const int row = idx.row(); + const auto item = m_model->itemAt(row); + const auto itemType = item->GetType(arch); + const auto itemFunc = item->GetFunction(); + + QMenu menu(this); + QAction *copySourceId = menu.addAction(tr("Copy Source")); + QAction *searchSource = menu.addAction(tr("Search Source")); + QAction *applyType = menu.addAction(tr("Apply Type")); + QAction *applyFunction = menu.addAction(tr("Apply to Current Function")); + + // We let users apply the type for types and functions (assuming the function has one) + // if the user applies a function, we actually will set the user type for the current view location function. + // For types, we will just throw it in the user types. + applyType->setEnabled(itemType != nullptr); + applyType->setVisible(applyType->isEnabled()); + + applyFunction->setEnabled(func != nullptr && itemFunc); + applyFunction->setVisible(applyFunction->isEnabled()); + + QAction *chosen = menu.exec(m_view->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == copySourceId) + QApplication::clipboard()->setText(QString::fromStdString(item->GetSource().ToString())); + else if (chosen == searchSource) + { + const QString sourceId = QString::fromStdString(item->GetSource().ToString()); + // TODO: This does not preserve the query that existed prior, doing so may result in duplicate source qualifiers. + m_query->setText(QStringLiteral("source:\"%1\"").arg(sourceId)); + } else if (chosen == applyType) + { + if (func && item->GetKind() == WARPContainerSearchItemKindFunction) + { + func->SetUserType(itemType); + binaryView->UpdateAnalysis(); + } + else + binaryView->DefineUserType(item->GetName(), itemType); + } else if (chosen == applyFunction) + { + itemFunc->Apply(*func); + binaryView->UpdateAnalysis(); + } + }); + + // This will start searching with an empty query once the widget is constructed. This is a decent behavior considering + // we should be heavily caching queries such as an empty one. + m_debounce.start(); +} diff --git a/plugins/warp/ui/shared/search.h b/plugins/warp/ui/shared/search.h new file mode 100644 index 00000000..86c90942 --- /dev/null +++ b/plugins/warp/ui/shared/search.h @@ -0,0 +1,234 @@ +#pragma once + +#include <QAbstractTableModel> +#include <QApplication> +#include <QClipboard> +#include <QFutureWatcher> +#include <QHeaderView> +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QObject> +#include <QPainter> +#include <QVector> +#include <QString> +#include <QStyledItemDelegate> +#include <QTableView> +#include <QTimer> +#include <QVBoxLayout> +#include <QtConcurrent/QtConcurrentRun> + +#include "uitypes.h" +#include "warp.h" + +// Will search in batches of 50 items. +constexpr auto SEARCH_PAGE_SIZE = 50; +// Will debounce the search for 350 MS. +constexpr auto SEARCH_DEBOUNCE_MS = 350; + +// Table model showing a paginated list of SearchItem. +class WarpSearchModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Columns : int + { + DisplayCol = 0, + KindCol, + SourceCol, + ColumnCount + }; + + explicit WarpSearchModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + int rowCount(const QModelIndex &parent) const override + { + if (parent.isValid()) return 0; + return m_items.size(); + } + + int columnCount(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return ColumnCount; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + // Begin a new search session: clears all current items and resets counters. + Q_INVOKABLE void beginNewSearch(std::size_t pageSize = SEARCH_PAGE_SIZE) + { + beginResetModel(); + m_items.clear(); + m_total = 0; + m_pageSize = pageSize; + endResetModel(); + emit cleared(); + // Immediately ask for the first page from offset 0 + emit fetchMoreRequested(0, m_pageSize); + } + + // Append results from a Warp::ContainerSearchResponse (infinite scroll, no replacement). + void appendResponse(const Warp::ContainerSearchResponse &resp) + { + // Update total first (so canFetchMore reflects new value) + m_total = resp.total; + if (resp.items.empty()) + { + emit responseUpdated(currentCount(), m_total); + return; + } + const int begin = m_items.size(); + const int end = begin + static_cast<int>(resp.items.size()) - 1; + beginInsertRows(QModelIndex(), begin, end); + for (const auto &refItem: resp.items) + m_items.push_back(refItem); + endInsertRows(); + emit responseUpdated(currentCount(), m_total); + } + + // Qt's infinite scroll hooks: the view will call these when near the end. + bool canFetchMore(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return static_cast<std::size_t>(m_items.size()) < m_total; + } + + void fetchMore(const QModelIndex &parent) override + { + Q_UNUSED(parent); + if (!canFetchMore({})) + return; + const std::size_t nextOffset = m_items.size(); + emit fetchMoreRequested(nextOffset, m_pageSize); + } + + // Convenience accessors. + std::size_t total() const { return m_total; } + int currentCount() const { return m_items.size(); } + void setPageSize(std::size_t pageSize) { m_pageSize = pageSize; } + + Q_INVOKABLE Warp::Ref<Warp::ContainerSearchItem> itemAt(int row) const + { + if (row < 0 || row >= m_items.size()) + return {}; + return m_items[row]; + } + +signals: + // Emitted when model is cleared for a new query. + void cleared(); + + // Emitted after items are appended or totals updated. + void responseUpdated(int currentCount, std::size_t total); + + // Request the next page; connect this to your async search runner. + void fetchMoreRequested(std::size_t offset, std::size_t limit); + +private: + QVector<Warp::Ref<Warp::ContainerSearchItem> > m_items; + std::size_t m_total = 0; + std::size_t m_pageSize = SEARCH_PAGE_SIZE; +}; + +// A delegate to render the DisplayCol text with an icon (mapped from KindCol) on the right. +class WarpSearchDelegate final : public QStyledItemDelegate +{ + Q_OBJECT + +public: + explicit WarpSearchDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) + { + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + // Base text size + space for an icon on the right + const QSize base = QStyledItemDelegate::sizeHint(option, index); + const int iconSide = 16; + return QSize(base.width(), qMax(base.height(), iconSide + 4)); // keep row height >= icon + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + static QString kindToIcon(const QString &kind) + { + // Example mapping; extend as needed + if (kind.compare(QStringLiteral("Function"), Qt::CaseInsensitive) == 0) + return QStringLiteral("ƒ"); + if (kind.compare(QStringLiteral("Type"), Qt::CaseInsensitive) == 0) + return QStringLiteral("𝑇"); + if (kind.compare(QStringLiteral("Source"), Qt::CaseInsensitive) == 0) + return QStringLiteral("📦"); + // Default: no icon + return {}; + } +}; + +class WarpSearchRunner : public QObject +{ + Q_OBJECT + +public: + explicit WarpSearchRunner(Warp::Ref<Warp::Container> container, QObject *parent = nullptr) + : QObject(parent), m_container(std::move(container)) + { + } + +signals: + // Emitted on the UI thread after the worker finishes + void pageReady(std::uint64_t sessionId, Warp::ContainerSearchResponse resp); + + void pageError(std::uint64_t sessionId, QString message); + +public slots: + // Start a new logical search session (new query or new filters) + void startSession(QString queryText, const std::optional<Warp::Source> &source) + { + m_queryText = std::move(queryText); + m_source = source; + m_sessionId.fetchAndAddRelaxed(1); + } + + // Fetch a page for the current session. Safe to call multiple times (infinite scroll). + void fetchPage(std::size_t offset, std::size_t limit); + +private: + std::uint64_t currentSession() const { return m_sessionId.loadRelaxed(); } + + Warp::Ref<Warp::Container> m_container; + QAtomicInteger<quint64> m_sessionId{0}; + QString m_queryText; + std::optional<Warp::Source> m_source; +}; + +class WarpSearchWidget : public QWidget +{ + Q_OBJECT + +public: + explicit WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent = nullptr); + + void setSourceFilter(const std::optional<Warp::Source> &src) + { + m_source = src; + m_debounce.start(); + } + +private: + WarpSearchModel *m_model; + WarpSearchRunner *m_runner; + QLineEdit *m_query; + QLabel *m_status; + QTableView *m_view; + QTimer m_debounce; + // Source to filter for if no source is provided in the search. + std::optional<Warp::Source> m_source; +}; |
