diff options
| author | Mason Reed <mason@vector35.com> | 2026-02-11 18:04:07 -0800 |
|---|---|---|
| committer | Mason Reed <35282038+emesare@users.noreply.github.com> | 2026-02-23 00:09:44 -0800 |
| commit | 37008b7fa16837d04c1658868646cad681cbe035 (patch) | |
| tree | 577c2b62ee47c78a5d31d11f2aa610e441f5c808 /plugins/bntl_utils/src/url.rs | |
| parent | 837f8590be80b7c98162e70e4f0c1814b83e9d7b (diff) | |
Add BNTL utility plugin
Allow users to easily create, diff, dump and validate type libraries
Supports the following formats:
- C header files (via core type parsers)
- Binary files (collects exported and imported functions)
- WinMD files (via `windows-metadata` crate)
- Existing type library files (for easy fixups)
- Apiset files (to resolve through forwarded windows dlls)
Can be invoked as a regular plugin via UI commands or via CLI.
Processing of type libraries inherently requires external linking, processing will automatically merge and deduplicate colliding type libraries so prefer to use inside a project or a directory and process all information (for a given platform) at once, rather than smaller invocations.
Diffstat (limited to 'plugins/bntl_utils/src/url.rs')
| -rw-r--r-- | plugins/bntl_utils/src/url.rs | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/plugins/bntl_utils/src/url.rs b/plugins/bntl_utils/src/url.rs new file mode 100644 index 00000000..16fe75d2 --- /dev/null +++ b/plugins/bntl_utils/src/url.rs @@ -0,0 +1,345 @@ +use binaryninja::collaboration::{RemoteFile, RemoteFolder, RemoteProject}; +use binaryninja::rc::Ref; +use std::fmt::Display; +use std::path::PathBuf; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +#[derive(Error, Debug, PartialEq)] +pub enum BnUrlParsingError { + #[error("Invalid URL format: {0}")] + UrlParseError(#[from] url::ParseError), + + #[error("Invalid scheme: expected 'binaryninja', found '{0}'")] + InvalidScheme(String), + + #[error("Invalid Enterprise path: missing server or project GUID")] + InvalidEnterprisePath, + + #[error("Invalid server URL in enterprise path")] + InvalidServerUrl, + + #[error("Invalid UUID: {0}")] + InvalidUuid(#[from] uuid::Error), + + #[error("Unknown or unsupported URL format")] + UnknownFormat, +} + +#[derive(Error, Debug)] +pub enum BnResourceError { + #[error("Enterprise server not found for address: {0}")] + RemoteNotFound(String), + + #[error("Remote connection error: {0}")] + RemoteConnectionError(String), + + #[error("Project not found with GUID: {0}")] + ProjectNotFound(String), + + #[error("Project resource not found with GUID: {0}")] + ItemNotFound(String), + + #[error("Local filesystem error: {0}")] + IoError(#[from] std::io::Error), +} + +#[derive(Debug, Clone)] +pub enum BnResource { + RemoteProject(Ref<RemoteProject>), + RemoteProjectFile(Ref<RemoteFile>), + RemoteProjectFolder(Ref<RemoteFolder>), + /// A remote file. + RemoteFile(Url), + /// A regular file on the local filesystem. + LocalFile(PathBuf), +} + +// TODO: Make the BnUrl from this. +impl Display for BnResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BnResource::RemoteProject(project) => write!(f, "RemoteProject({})", project.id()), + BnResource::RemoteProjectFile(file) => write!(f, "RemoteFile({})", file.id()), + BnResource::RemoteProjectFolder(folder) => write!(f, "RemoteFolder({})", folder.id()), + BnResource::RemoteFile(url) => write!(f, "RemoteFile({})", url), + BnResource::LocalFile(path) => write!(f, "LocalFile({})", path.display()), + } + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum BnParsedUrlKind { + Enterprise { + server: Url, + project_guid: Uuid, + /// Optional GUID of the project item, currently can be a folder or a file. + item_guid: Option<Uuid>, + }, + // TODO: Local projects? + RemoteFile(Url), + LocalFile(PathBuf), +} + +#[derive(Debug, Clone)] +pub struct BnParsedUrl { + pub kind: BnParsedUrlKind, + pub expression: Option<String>, +} + +impl BnParsedUrl { + pub fn parse(input: &str) -> Result<Self, BnUrlParsingError> { + let parsed = Url::parse(input)?; + if parsed.scheme() != "binaryninja" { + return Err(BnUrlParsingError::InvalidScheme( + parsed.scheme().to_string(), + )); + } + + let expression = parsed + .query_pairs() + .find(|(k, _)| k == "expr") + .map(|(_, v)| v.into_owned()); + + let kind = match parsed.host_str() { + // TODO: This should really go down the same path as the remote file parsing, if it + // TODO: matches the host of an enterprise server... But that requires us to change how + // TODO: the core outputs these enterprise URLs... + // Case: binaryninja://enterprise/... + Some("enterprise") => { + let segments: Vec<&str> = + parsed.path().split('/').filter(|s| !s.is_empty()).collect(); + + if segments.len() < 3 { + return Err(BnUrlParsingError::InvalidEnterprisePath); + } + + let (server_parts, resource_parts) = if segments.len() >= 4 { + ( + &segments[..segments.len() - 2], + &segments[segments.len() - 2..], + ) + } else { + ( + &segments[..segments.len() - 1], + &segments[segments.len() - 1..], + ) + }; + + BnParsedUrlKind::Enterprise { + server: Url::parse(&server_parts.join("/")) + .map_err(|_| BnUrlParsingError::InvalidServerUrl)?, + project_guid: Uuid::parse_str(resource_parts[0])?, + item_guid: resource_parts + .get(1) + .map(|s| Uuid::parse_str(s)) + .transpose()?, + } + } + // Case: binaryninja:///bin/ls + None | Some("") + if parsed.path().starts_with('/') && !parsed.path().starts_with("/https") => + { + BnParsedUrlKind::LocalFile(PathBuf::from(parsed.path())) + } + // Case: binaryninja:https://... + _ => { + let path = parsed.path(); + if path.starts_with("https:/") || path.starts_with("http:/") { + let nested_url = path.replacen(":/", "://", 1); + BnParsedUrlKind::RemoteFile( + Url::parse(&nested_url).map_err(BnUrlParsingError::UrlParseError)?, + ) + } else { + return Err(BnUrlParsingError::UnknownFormat); + } + } + }; + + Ok(BnParsedUrl { kind, expression }) + } + + pub fn to_resource(&self) -> Result<BnResource, BnResourceError> { + match &self.kind { + BnParsedUrlKind::Enterprise { + server, + project_guid, + item_guid, + } => { + // NOTE: We must strip the trailing slash from the server URL, because the core will + // not accept it otherwise, we should probably have a fuzzy get_remote_by_address here, + // so we can accept either with or without the trailing slash, but for now we'll just + // strip it. + let server_addr = server.as_str().strip_suffix('/').unwrap_or(server.as_str()); + let remote = binaryninja::collaboration::get_remote_by_address(server_addr) + .ok_or_else(|| BnResourceError::RemoteNotFound(server_addr.to_string()))?; + if !remote.is_connected() { + remote.connect().map_err(|_| { + BnResourceError::RemoteConnectionError(server_addr.to_string()) + })?; + } + + let project = remote + .get_project_by_id(&project_guid.to_string()) + .ok() + .flatten() + .ok_or_else(|| BnResourceError::ProjectNotFound(project_guid.to_string()))?; + + match item_guid { + Some(item_guid) => { + let item_guid_str = item_guid.to_string(); + + // Check if it's a folder first + if let Some(folder) = + project.get_folder_by_id(&item_guid_str).ok().flatten() + { + return Ok(BnResource::RemoteProjectFolder(folder)); + } + + // Then check if it's a file + let file = project + .get_file_by_id(&item_guid_str) + .ok() + .flatten() + .ok_or_else(|| BnResourceError::ItemNotFound(item_guid_str))?; + + Ok(BnResource::RemoteProjectFile(file)) + } + None => Ok(BnResource::RemoteProject(project)), + } + } + BnParsedUrlKind::RemoteFile(remote_url) => { + Ok(BnResource::RemoteFile(remote_url.clone())) + } + BnParsedUrlKind::LocalFile(local_path) => Ok(BnResource::LocalFile(local_path.clone())), + } + } +} + +impl Display for BnParsedUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.kind { + BnParsedUrlKind::Enterprise { + server, + project_guid, + item_guid, + } => write!( + f, + "binaryninja://enterprise/{}/{}{}", + server.as_str().strip_suffix('/').unwrap_or(server.as_str()), + project_guid, + item_guid + .map(|guid| format!("/{}", guid)) + .unwrap_or_default() + ), + BnParsedUrlKind::RemoteFile(remote_url) => write!(f, "{}", remote_url), + BnParsedUrlKind::LocalFile(local_path) => { + write!(f, "binaryninja:///{}", local_path.display()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_enterprise_full() { + let input = "binaryninja://enterprise/https://enterprise.test.com/0268b954-0d7b-41c3-a603-960a59fdd0f7/0268b954-0d7b-41c3-a603-960a59fdd0f6?expr=sub_1234"; + let action = BnParsedUrl::parse(input).unwrap(); + + if let BnParsedUrlKind::Enterprise { + server, + project_guid, + item_guid: project_file, + } = action.kind + { + assert_eq!(server.as_str(), "https://enterprise.test.com/"); + assert_eq!( + project_guid, + Uuid::parse_str("0268b954-0d7b-41c3-a603-960a59fdd0f7").unwrap() + ); + assert_eq!( + project_file, + Some(Uuid::parse_str("0268b954-0d7b-41c3-a603-960a59fdd0f6").unwrap()) + ); + } else { + panic!("Wrong target type"); + } + assert_eq!(action.expression, Some("sub_1234".to_string())); + } + + #[test] + fn test_parse_enterprise_no_file() { + let input = "binaryninja://enterprise/https://enterprise.test.com/0268b954-0d7b-41c3-a603-960a59fdd0f7/"; + let action = BnParsedUrl::parse(input).unwrap(); + + if let BnParsedUrlKind::Enterprise { + project_guid, + item_guid: project_file, + .. + } = action.kind + { + assert_eq!( + project_guid, + Uuid::parse_str("0268b954-0d7b-41c3-a603-960a59fdd0f7").unwrap() + ); + assert_eq!(project_file, None); + } else { + panic!("Wrong target type"); + } + } + + #[test] + fn test_parse_remote_file() { + let input = "binaryninja:https://captf.com/2015/plaidctf/pwnable/datastore.elf?expr=main"; + let action = BnParsedUrl::parse(input).unwrap(); + + match action.kind { + BnParsedUrlKind::RemoteFile(url) => { + assert_eq!(url.host_str(), Some("captf.com")); + assert!(url.path().ends_with("datastore.elf")); + } + _ => panic!("Expected RemoteFile"), + } + assert_eq!(action.expression, Some("main".to_string())); + } + + #[test] + fn test_parse_local_file() { + let input = "binaryninja:///bin/ls?expr=sub_2830"; + let action = BnParsedUrl::parse(input).unwrap(); + + match action.kind { + BnParsedUrlKind::LocalFile(path) => assert_eq!(path.to_string_lossy(), "/bin/ls"), + _ => panic!("Expected LocalFile"), + } + assert_eq!(action.expression, Some("sub_2830".to_string())); + } + + #[test] + fn test_invalid_scheme() { + let input = "https://google.com"; + let result = BnParsedUrl::parse(input); + assert!(matches!(result, Err(BnUrlParsingError::InvalidScheme(_)))); + } + + #[test] + fn test_missing_enterprise_guid() { + let input = "binaryninja://enterprise/https://internal.us/"; + let result = BnParsedUrl::parse(input); + assert_eq!( + result.unwrap_err(), + BnUrlParsingError::InvalidEnterprisePath + ); + } + + #[test] + fn test_invalid_uuid_format() { + let input = "binaryninja://enterprise/https://internal.us/not-a-uuid/"; + let result = BnParsedUrl::parse(input); + assert!(matches!(result, Err(BnUrlParsingError::InvalidUuid(_)))); + } +} |
