use std::{
collections::BTreeMap,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
};
use candid::Principal;
use regex::Regex;
use rmcp::schemars;
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
#[derive(Serialize, Clone, Debug)]
pub struct Found {
pub canister_id: String,
pub label: Option<String>,
pub sources: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DiscoveredCanister {
pub canister_id: String,
pub label: Option<String>,
pub name: Option<String>,
pub kind: Option<String>,
pub sources: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub oql: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_doc_available: Option<bool>,
}
impl From<&Found> for DiscoveredCanister {
fn from(f: &Found) -> Self {
Self {
canister_id: f.canister_id.clone(),
label: f.label.clone(),
name: f.name.clone(),
kind: f.kind.clone(),
sources: f.sources.clone(),
oql: None,
api_doc_available: None,
}
}
}
pub fn is_app_data_candidate(c: &DiscoveredCanister) -> bool {
let app_owned = c.sources.iter().any(|s| {
s == "ai-connect.html" || s == "ic-app.json" || s == "env.json" || s.starts_with("bundle")
});
let is_frontend =
c.label.as_deref() == Some("frontend") || c.sources == ["header"];
let is_system = c.kind.as_deref().is_some_and(|k| {
let k = k.to_ascii_lowercase();
["ledger", "governance", "index", "archive", "root", "sns", "nns", "cycles", "cmc"]
.iter()
.any(|s| k.contains(s))
});
app_owned && !is_frontend && !is_system
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DiscoverCanistersArgs {
pub domain: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DiscoverOutput {
pub domain: String,
pub canisters: Vec<DiscoveredCanister>,
pub omitted: usize,
}
#[derive(Debug)]
pub struct Discovery {
pub canisters: Vec<Found>,
pub omitted: usize,
}
impl From<(String, Discovery)> for DiscoverOutput {
fn from((domain, d): (String, Discovery)) -> Self {
Self {
domain,
canisters: d.canisters.iter().map(DiscoveredCanister::from).collect(),
omitted: d.omitted,
}
}
}
fn canister_re() -> Regex {
Regex::new(r"[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{5}-cai").unwrap()
}
fn canisters_from_env_json(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(text) {
for (k, v) in map {
if k.to_lowercase().contains("canister") {
if let Some(s) = v.as_str() {
out.push((s.to_string(), k));
}
}
}
}
out
}
fn parse_meta(html: &str, name: &str) -> Option<String> {
let bytes = html.as_bytes();
let mut i = 0;
while i + 5 <= bytes.len() {
if bytes[i] != b'<' || !bytes[i + 1..i + 5].eq_ignore_ascii_case(b"meta") {
i += 1;
continue;
}
let after = i + 5;
if !matches!(bytes.get(after), Some(b' ' | b'\t' | b'\n' | b'\r' | b'/' | b'>')) {
i = after;
continue;
}
let rest = &html[after..];
let Some(end) = rest.find('>') else {
break;
};
let tag = &rest[..end];
if attr(tag, "name").as_deref() == Some(name) {
if let Some(content) = attr(tag, "content") {
return Some(content);
}
}
i = after + end;
}
None
}
fn attr(tag: &str, key: &str) -> Option<String> {
let bytes = tag.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let name_start = i;
while i < bytes.len()
&& !bytes[i].is_ascii_whitespace()
&& bytes[i] != b'='
&& bytes[i] != b'"'
&& bytes[i] != b'\''
{
i += 1;
}
let name = &tag[name_start..i];
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'=' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
let vstart = i + 1;
let mut j = vstart;
while j < bytes.len() && bytes[j] != quote {
j += 1;
}
if j >= bytes.len() {
return None; }
if name.eq_ignore_ascii_case(key) {
return Some(tag[vstart..j].to_string());
}
i = j + 1;
} else {
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
}
}
if i == name_start {
i += 1;
}
}
None
}
const MAX_MANIFEST_CANISTERS: usize = 100;
#[derive(Deserialize)]
struct AppManifest {
#[serde(default)]
canisters: Vec<AppManifestEntry>,
#[serde(default)]
derivation_origin: Option<String>,
}
#[derive(Deserialize)]
struct AppManifestEntry {
#[serde(default)]
id: String,
#[serde(default)]
role: Option<String>,
#[serde(default)]
description: Option<String>,
}
fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option<String>)> {
let Ok(m) = serde_json::from_str::<AppManifest>(text) else {
return Vec::new();
};
m.canisters
.into_iter()
.filter(|e| !e.id.trim().is_empty())
.take(MAX_MANIFEST_CANISTERS)
.map(|e| {
let role = e.role.as_deref().map(clean_label).filter(|s| !s.is_empty());
let desc = e.description.as_deref().map(clean_label).filter(|s| !s.is_empty());
let label = match (role, desc) {
(Some(r), Some(d)) => Some(format!("{r} — {d}")),
(Some(r), None) => Some(r),
(None, Some(d)) => Some(d),
(None, None) => None,
};
(e.id.trim().to_string(), label)
})
.collect()
}
fn normalize_origin(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Some((scheme, _)) = raw.split_once("://") {
if !scheme.eq_ignore_ascii_case("https") {
return None;
}
}
let candidate = if raw.contains("://") { raw.to_string() } else { format!("https://{raw}") };
let url = url::Url::parse(&candidate).ok()?;
if url.scheme() != "https" {
return None;
}
if !url.username().is_empty() || url.password().is_some() {
return None;
}
let origin = url.origin();
if !origin.is_tuple() {
return None;
}
Some(origin.ascii_serialization())
}
fn declared_derivation_origin(manifest_text: &str) -> Option<String> {
let m = serde_json::from_str::<AppManifest>(manifest_text).ok()?;
normalize_origin(m.derivation_origin?.as_str())
}
#[derive(Deserialize)]
struct AltOrigins {
#[serde(default, rename = "alternativeOrigins")]
alternative_origins: Vec<String>,
}
fn parse_alternative_origins(text: &str) -> Vec<String> {
serde_json::from_str::<AltOrigins>(text)
.map(|a| {
a.alternative_origins
.iter()
.filter_map(|s| normalize_origin(s))
.take(MAX_MANIFEST_CANISTERS)
.collect()
})
.unwrap_or_default()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DerivationSource {
Declared,
Known,
AppUrlDefault,
}
impl DerivationSource {
pub fn as_str(self) -> &'static str {
match self {
DerivationSource::Declared => "declared",
DerivationSource::Known => "known",
DerivationSource::AppUrlDefault => "app_url_default",
}
}
}
const KNOWN_DERIVATION_ORIGINS: &[(&str, &str)] = &[
("nns.ic0.app", "https://nns.ic0.app"),
("nns.internetcomputer.org", "https://nns.ic0.app"),
("beta.nns.internetcomputer.org", "https://nns.ic0.app"),
("beta.nns.ic0.app", "https://nns.ic0.app"),
("sns.internetcomputer.org", "https://nns.ic0.app"),
("oisy.com", "https://oisy.com"),
("beta.oisy.com", "https://oisy.com"),
("v7iq7-yiaaa-aaaan-qmrtq-cai.icp0.io", "https://oisy.com"),
("cha4i-riaaa-aaaan-qeccq-cai.icp0.io", "https://oisy.com"),
("signer.oisy.com", "https://oisy.com"),
("legacy-signer.oisy.com", "https://oisy.com"),
("beta.signer.oisy.com", "https://oisy.com"),
("beta.legacy-signer.oisy.com", "https://oisy.com"),
("multidex.ai", "https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io"),
("hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io", "https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io"),
("hcv4s-uaaaa-aaabq-qaaba-cai.icp.net", "https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io"),
("app.icpswap.com", "https://app.icpswap.com"),
];
fn known_derivation_origin(host: &str) -> Option<&'static str> {
KNOWN_DERIVATION_ORIGINS
.iter()
.find(|(h, _)| *h == host)
.map(|(_, origin)| *origin)
}
struct KnownApp {
name: &'static str,
aliases: &'static [&'static str],
app_url: &'static str,
}
const KNOWN_APPS: &[KnownApp] = &[
KnownApp { name: "NNS", aliases: &["nns", "nnsdapp"], app_url: "https://nns.internetcomputer.org" },
KnownApp { name: "Oisy", aliases: &["oisy", "oisywallet"], app_url: "https://oisy.com" },
KnownApp { name: "MULTI/DEX", aliases: &["multidex"], app_url: "https://multidex.ai" },
KnownApp { name: "ICPSwap", aliases: &["icpswap"], app_url: "https://app.icpswap.com" },
];
fn known_app_derivation_origin(app: &KnownApp) -> &'static str {
url::Url::parse(app.app_url)
.ok()
.and_then(|u| u.host_str().map(str::to_ascii_lowercase))
.and_then(|h| known_derivation_origin(&h))
.unwrap_or(app.app_url)
}
fn find_known_app(query: &str) -> Option<&'static KnownApp> {
const MAX_QUERY_BYTES: usize = 256;
if query.len() > MAX_QUERY_BYTES {
return None;
}
const MAX_TOKENS: usize = 64;
let tokens: Vec<String> = query
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.take(MAX_TOKENS)
.map(str::to_ascii_lowercase)
.collect();
if tokens.is_empty() {
return None;
}
let max_alias = KNOWN_APPS
.iter()
.flat_map(|app| app.aliases.iter())
.map(|a| a.len())
.max()
.unwrap_or(0);
for start in 0..tokens.len() {
let mut acc = String::new();
for t in &tokens[start..] {
acc.push_str(t);
if acc.len() > max_alias {
break;
}
if let Some(app) = KNOWN_APPS.iter().find(|app| app.aliases.contains(&acc.as_str())) {
return Some(app);
}
}
}
None
}
pub struct AppIdentity {
pub application_origin: String,
pub derivation_origin: String,
pub derivation_origin_source: DerivationSource,
pub alternative_origins: Vec<String>,
pub application_is_ic: Option<bool>,
}
async fn fetch_alternative_origins(origin: &str) -> Vec<String> {
let (url, pinned) = match resolve_public_url(origin).await {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let client = match site_client(&host, &pinned) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let origin = url.origin().ascii_serialization();
match client.get(format!("{origin}/.well-known/ii-alternative-origins")).send().await {
Ok(resp) if resp.status().is_success() => {
parse_alternative_origins(&read_capped(resp, MAX_META_BYTES).await)
}
_ => Vec::new(),
}
}
fn header_is_ic_principal(headers: &reqwest::header::HeaderMap) -> bool {
headers
.get("x-ic-canister-id")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.map_or(false, |v| Principal::from_text(v).is_ok())
}
fn ic_evidence_from(resp: &reqwest::Response, expected_origin: &str) -> bool {
header_is_ic_principal(resp.headers())
&& resp.url().origin().ascii_serialization() == expected_origin
}
fn derivation_origin_authorized(
application_origin: &str,
declared: &str,
declared_alt_origins: &[String],
) -> bool {
declared == application_origin || declared_alt_origins.iter().any(|o| o == application_origin)
}
fn decide_declared_origin(
application_origin: &str,
declared: Option<&str>,
declared_alt_origins: &[String],
) -> Result<(String, DerivationSource), String> {
let Some(declared) = declared else {
return Ok((application_origin.to_string(), DerivationSource::AppUrlDefault));
};
if derivation_origin_authorized(application_origin, declared, declared_alt_origins) {
return Ok((declared.to_string(), DerivationSource::Declared));
}
Err(format!(
"the app at {application_origin} declares derivation origin {declared}, but {declared} \
does not authorize it — {application_origin} is not listed in {declared}'s \
/.well-known/ii-alternative-origins (or that list could not be fetched). Refusing to \
derive an identity here rather than use a wrong one."
))
}
struct DeclaredResolution {
derivation_origin: String,
source: DerivationSource,
ic_evidence: bool,
alt_origins: Option<Vec<String>>,
}
impl DeclaredResolution {
fn app_default(application_origin: &str, ic_evidence: bool) -> Self {
Self {
derivation_origin: application_origin.to_string(),
source: DerivationSource::AppUrlDefault,
ic_evidence,
alt_origins: None,
}
}
}
async fn resolve_declared_origin(
client: &reqwest::Client,
application_origin: &str,
) -> Result<DeclaredResolution, String> {
let Ok(resp) = client
.get(format!("{application_origin}/.well-known/ic-app.json"))
.send()
.await
else {
return Ok(DeclaredResolution::app_default(application_origin, false));
};
let ic_evidence = ic_evidence_from(&resp, application_origin);
if !resp.status().is_success() {
return Ok(DeclaredResolution::app_default(application_origin, ic_evidence));
}
let text = read_capped(resp, MAX_META_BYTES).await;
let declared = declared_derivation_origin(&text);
let cross_origin = declared.as_deref().is_some_and(|d| d != application_origin);
let alts = if cross_origin {
fetch_alternative_origins(declared.as_deref().unwrap_or_default()).await
} else {
Vec::new()
};
let (derivation_origin, source) =
match decide_declared_origin(application_origin, declared.as_deref(), &alts) {
Ok(decision) => decision,
Err(e) => {
tracing::warn!(
application_origin = %application_origin,
declared = declared.as_deref().unwrap_or_default(),
"refusing a cross-origin derivation_origin declaration that could not be authorized"
);
return Err(e);
}
};
Ok(DeclaredResolution {
derivation_origin,
source,
ic_evidence,
alt_origins: cross_origin.then_some(alts),
})
}
pub async fn resolve_app_identity(app_url: &str, want_alt_origins: bool) -> Result<AppIdentity, String> {
let base = normalize(app_url);
let (base_url, pinned) = resolve_public_url(&base).await?;
let host = base_url.host_str().unwrap_or_default().to_ascii_lowercase();
let client = site_client(&host, &pinned)?;
let application_origin = base_url.origin().ascii_serialization();
let resolved = resolve_declared_origin(&client, &application_origin).await?;
let mut derivation_origin = resolved.derivation_origin;
let mut derivation_origin_source = resolved.source;
let mut ic_evidence = resolved.ic_evidence;
let resolved_alt_origins = resolved.alt_origins;
if derivation_origin_source == DerivationSource::AppUrlDefault {
if let Some(known) = known_derivation_origin(&host) {
derivation_origin = known.to_string();
derivation_origin_source = DerivationSource::Known;
}
}
let application_is_ic = if derivation_origin_source == DerivationSource::AppUrlDefault {
if !ic_evidence {
let resp = client
.get(format!("{application_origin}/"))
.send()
.await
.map_err(|e| format!("could not reach {application_origin}: {e}"))?;
ic_evidence = ic_evidence_from(&resp, &application_origin);
}
Some(ic_evidence)
} else {
None
};
let alternative_origins = if want_alt_origins {
match resolved_alt_origins {
Some(alts) => alts,
None => fetch_alternative_origins(&derivation_origin).await,
}
} else {
Vec::new()
};
Ok(AppIdentity {
application_origin,
derivation_origin,
derivation_origin_source,
alternative_origins,
application_is_ic,
})
}
pub fn similar_known_app(app_url: &str) -> Option<AppMatch> {
let host = url::Url::parse(&normalize(app_url))
.ok()?
.host_str()?
.to_ascii_lowercase();
if known_derivation_origin(&host).is_some() {
return None; }
let app = find_known_app(&host)?;
Some(AppMatch {
name: app.name.to_string(),
app_url: app.app_url.to_string(),
derivation_origin: known_app_derivation_origin(app).to_string(),
})
}
pub enum AppQuery {
Known(AppMatch),
Url(String),
UnknownName,
}
pub fn classify_app_query(query: &str) -> AppQuery {
let q = query.trim();
if q.contains("://") {
return AppQuery::Url(q.to_string());
}
if let Some(app) = find_known_app(q) {
return AppQuery::Known(AppMatch {
name: app.name.to_string(),
app_url: app.app_url.to_string(),
derivation_origin: known_app_derivation_origin(app).to_string(),
});
}
if q.contains('.') {
AppQuery::Url(q.to_string())
} else {
AppQuery::UnknownName
}
}
fn clean_label(s: &str) -> String {
const MAX_LABEL_CHARS: usize = 120;
s.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.take(MAX_LABEL_CHARS)
.collect::<String>()
.trim()
.to_string()
}
fn normalize(domain: &str) -> String {
let d = domain.trim().trim_end_matches('/');
let lower = d.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
d.to_string()
} else {
format!("https://{d}")
}
}
fn ip_is_global(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => ipv4_is_global(v4),
IpAddr::V6(v6) => ipv6_is_global(v6),
}
}
fn ipv4_is_global(ip: &Ipv4Addr) -> bool {
let o = ip.octets();
!(ip.is_unspecified() || o[0] == 0 || ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_multicast() || (o[0] == 100 && (o[1] & 0xc0) == 64) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || (o[0] == 198 && (o[1] & 0xfe) == 18) || o[0] >= 240) }
fn ipv6_is_global(ip: &Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4() {
return ipv4_is_global(&v4);
}
let seg = ip.segments();
!(ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] == 0x2001 && seg[1] == 0x0db8) || (seg[0] == 0x0064 && seg[1] == 0xff9b) || seg[0] == 0x2002 || (seg[0] == 0x2001 && seg[1] == 0x0000)) }
async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec<SocketAddr>), String> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid discovery URL {raw}: {e}"))?;
if url.scheme() != "https" {
return Err(format!(
"refusing to fetch {raw}: only https:// discovery targets are allowed (SSRF guard)"
));
}
let port = url.port_or_known_default().unwrap_or(443);
let addrs: Vec<SocketAddr> = match url.host() {
Some(url::Host::Ipv4(v4)) => vec![SocketAddr::new(IpAddr::V4(v4), port)],
Some(url::Host::Ipv6(v6)) => vec![SocketAddr::new(IpAddr::V6(v6), port)],
Some(url::Host::Domain(host)) => tokio::net::lookup_host((host, port))
.await
.map_err(|e| format!("could not resolve {host}: {e}"))?
.collect(),
None => return Err(format!("refusing to fetch {raw}: no host")),
};
if addrs.is_empty() {
return Err(format!("refusing to fetch {raw}: host did not resolve"));
}
if let Some(bad) = addrs.iter().find(|a| !ip_is_global(&a.ip())) {
return Err(format!(
"refusing to fetch {raw}: it resolves to a non-public address ({}) — discovery is \
restricted to public hosts (SSRF guard)",
bad.ip()
));
}
Ok((url, addrs))
}
fn redirect_hop_ok(next: &url::Url, prev_host: Option<&str>) -> bool {
if next.scheme() != "https" {
return false;
}
match next.host() {
Some(url::Host::Ipv4(v4)) => ip_is_global(&IpAddr::V4(v4)),
Some(url::Host::Ipv6(v6)) => ip_is_global(&IpAddr::V6(v6)),
Some(url::Host::Domain(h)) => prev_host == Some(h),
None => false,
}
}
fn ssrf_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let prev_host = attempt
.previous()
.last()
.and_then(|u| u.host_str())
.map(str::to_string);
if attempt.previous().len() >= 10 {
attempt.error("too many redirects")
} else if redirect_hop_ok(attempt.url(), prev_host.as_deref()) {
attempt.follow()
} else {
attempt.stop()
}
})
}
fn site_client(host: &str, addrs: &[SocketAddr]) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("ic-mcp-discover/0.1")
.timeout(std::time::Duration::from_secs(15))
.redirect(ssrf_redirect_policy())
.resolve_to_addrs(host, addrs)
.build()
.map_err(|e| format!("http client: {e}"))
}
const MAX_BODY_BYTES: usize = 4 * 1024 * 1024; const MAX_ENV_JSON_BYTES: usize = 256 * 1024; const MAX_META_BYTES: usize = 256 * 1024; const MAX_SCAN_BYTES: usize = 8 * 1024 * 1024;
async fn read_capped(mut resp: reqwest::Response, max: usize) -> String {
let mut buf: Vec<u8> = Vec::new();
loop {
if buf.len() >= max {
break;
}
match resp.chunk().await {
Ok(Some(chunk)) => {
let take = (max - buf.len()).min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if take < chunk.len() {
break; }
}
Ok(None) => break,
Err(_) => break,
}
}
String::from_utf8_lossy(&buf).into_owned()
}
#[derive(Default)]
struct Findings {
map: BTreeMap<String, Found>,
dropped: usize,
}
impl Findings {
const MAX: usize = 1024;
const MAX_SOURCES: usize = 8;
fn add(&mut self, id: &str, label: Option<String>, source: String) {
if Principal::from_text(id).is_err() {
return;
}
if let Some(entry) = self.map.get_mut(id) {
if entry.label.is_none() {
entry.label = label;
}
if entry.sources.len() < Self::MAX_SOURCES && !entry.sources.contains(&source) {
entry.sources.push(source);
}
} else if self.map.len() < Self::MAX {
self.map.insert(
id.to_string(),
Found { canister_id: id.to_string(), label, sources: vec![source], name: None, kind: None },
);
} else {
self.dropped = self.dropped.saturating_add(1);
}
}
}
pub async fn discover(domain: &str) -> Result<Discovery, String> {
let base = normalize(domain);
let (base_url, pinned) = resolve_public_url(&base).await?;
let host = base_url.host_str().unwrap_or_default().to_string();
let client = site_client(&host, &pinned)?;
let origin = base_url.origin().ascii_serialization();
let mut found = Findings::default();
if let Ok(resp) = client.get(format!("{origin}/ai-connect.html")).send().await {
if resp.status().is_success() {
let page = read_capped(resp, MAX_META_BYTES).await;
if let Some(id) = parse_meta(&page, "ic:canister-id") {
found.add(
id.trim(),
Some("main backend (App Connect)".into()),
"ai-connect.html".into(),
);
}
}
}
if let Ok(resp) = client.get(format!("{origin}/.well-known/ic-app.json")).send().await {
if resp.status().is_success() {
let text = read_capped(resp, MAX_META_BYTES).await;
for (id, label) in canisters_from_app_manifest(&text) {
found.add(&id, label, "ic-app.json".into());
}
}
}
let resp = client
.get(&base)
.send()
.await
.map_err(|e| format!("could not reach {base}: {e}"))?;
if let Some(id) = resp
.headers()
.get("x-ic-canister-id")
.and_then(|v| v.to_str().ok())
{
found.add(id, Some("frontend".into()), "header".into());
}
let html = read_capped(resp, MAX_BODY_BYTES).await;
if let Ok(resp) = client.get(format!("{origin}/env.json")).send().await {
if resp.status().is_success() {
let text = read_capped(resp, MAX_ENV_JSON_BYTES).await;
for (id, label) in canisters_from_env_json(&text) {
found.add(&id, Some(label), "env.json".into());
}
}
}
let mut blob = html.clone();
let script_re = Regex::new(r#"["'](/[^"'<> ]+?\.js)["']"#).unwrap();
const MAX_SCRIPT_PATHS: usize = 128;
let mut scripts: Vec<String> = Vec::new();
for c in script_re.captures_iter(&html) {
let path = &c[1];
if !scripts.iter().any(|s| s == path) {
scripts.push(path.to_string());
if scripts.len() >= MAX_SCRIPT_PATHS {
break;
}
}
}
scripts.sort();
for s in scripts.iter().take(20) {
if blob.len() >= MAX_SCAN_BYTES {
break; }
if let Ok(resp) = client.get(format!("{origin}{s}")).send().await {
blob.push('\n');
let room = MAX_SCAN_BYTES.saturating_sub(blob.len());
let t = read_capped(resp, room.min(MAX_BODY_BYTES)).await;
blob.push_str(&t);
}
}
let label_re = Regex::new(
r#"([A-Z][A-Z0-9_]*CANISTER_ID)["'\s:=]+([a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{5}-cai)"#,
)
.unwrap();
for c in label_re.captures_iter(&blob) {
let label = clean_label(&c[1]);
found.add(&c[2], Some(label.clone()), format!("bundle:{label}"));
}
for m in canister_re().find_iter(&blob) {
found.add(m.as_str(), None, "bundle".into());
}
let rank = |f: &Found| {
if f.sources.iter().any(|s| s == "ai-connect.html") {
0
} else if f.sources.iter().any(|s| s == "ic-app.json") {
1
} else if f.sources.iter().any(|s| s == "header") {
2
} else if f.sources.iter().any(|s| s == "env.json") {
3
} else if f.sources.iter().any(|s| s.starts_with("bundle:")) {
4
} else {
5
}
};
let dropped = found.dropped;
let mut out: Vec<Found> = found.map.into_values().collect();
out.sort_by(|a, b| rank(a).cmp(&rank(b)).then_with(|| a.canister_id.cmp(&b.canister_id)));
let mut bounded = bound_findings(out, dropped);
enrich_with_dashboard(&client, &mut bounded.canisters).await;
Ok(bounded)
}
fn bound_findings(mut out: Vec<Found>, extra_omitted: usize) -> Discovery {
const MAX_BARE_BUNDLE: usize = 20;
const MAX_CANISTERS: usize = 50;
let total = out.len();
let mut bare_seen = 0usize;
out.retain(|f| {
if f.sources.iter().all(|s| s == "bundle") {
bare_seen += 1;
bare_seen <= MAX_BARE_BUNDLE
} else {
true
}
});
out.truncate(MAX_CANISTERS);
Discovery { omitted: extra_omitted + (total - out.len()), canisters: out }
}
async fn enrich_with_dashboard(client: &reqwest::Client, found: &mut [Found]) {
const MAX_ENRICH: usize = 50;
let mut set = JoinSet::new();
for (i, f) in found.iter().enumerate().take(MAX_ENRICH) {
let client = client.clone();
let id = f.canister_id.clone();
set.spawn(async move { (i, lookup_canister(&client, &id).await.ok()) });
}
while let Some(res) = set.join_next().await {
if let Ok((i, Some(info))) = res {
found[i].name = info.name;
found[i].kind = info.canister_type;
}
}
}
fn dashboard_api() -> String {
api_base("IC_DASHBOARD_API", "https://ic-api.internetcomputer.org")
}
fn icrc_api() -> String {
api_base("IC_ICRC_API", "https://icrc-api.internetcomputer.org")
}
fn sns_api() -> String {
api_base("IC_SNS_API", "https://sns-api.internetcomputer.org")
}
fn api_base(var: &str, default: &str) -> String {
resolve_base(std::env::var(var).ok(), default)
}
fn resolve_base(configured: Option<String>, default: &str) -> String {
let trimmed = configured.as_deref().unwrap_or("").trim().trim_end_matches('/');
if trimmed.is_empty() {
default.trim_end_matches('/').to_string()
} else {
trimmed.to_string()
}
}
pub fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent("ic-mcp-discover/0.1")
.timeout(std::time::Duration::from_secs(15))
.redirect(ssrf_redirect_policy())
.build()
.map_err(|e| format!("http client: {e}"))
}
#[derive(Serialize, Clone, Debug, Default)]
pub struct CanisterInfo {
pub canister_id: String,
pub name: Option<String>,
pub canister_type: Option<String>,
pub controllers: Vec<String>,
pub subnet_id: Option<String>,
pub module_hash: Option<String>,
pub language: Option<String>,
pub latest_upgrade_proposal: Option<u64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct LookupCanisterArgs {
pub canister_id: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CanisterIdentityOutput {
pub canister_id: String,
pub name: Option<String>,
pub canister_type: Option<String>,
pub controllers: Vec<String>,
pub subnet_id: Option<String>,
pub module_hash: Option<String>,
pub language: Option<String>,
pub latest_upgrade_proposal: Option<u64>,
}
impl From<CanisterInfo> for CanisterIdentityOutput {
fn from(info: CanisterInfo) -> Self {
Self {
canister_id: info.canister_id,
name: info.name,
canister_type: info.canister_type,
controllers: info.controllers,
subnet_id: info.subnet_id,
module_hash: info.module_hash,
language: info.language,
latest_upgrade_proposal: info.latest_upgrade_proposal,
}
}
}
#[derive(Deserialize)]
struct RawCanister {
canister_id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
canister_type: Option<String>,
#[serde(default)]
controllers: Vec<String>,
#[serde(default)]
subnet_id: Option<String>,
#[serde(default)]
module_hash: Option<String>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
upgrades: Option<Vec<RawUpgrade>>,
}
#[derive(Deserialize)]
struct RawUpgrade {
#[serde(default)]
executed_timestamp_seconds: i64,
#[serde(default)]
proposal_id: Option<u64>,
}
fn non_empty(s: Option<String>) -> Option<String> {
s.filter(|v| !v.trim().is_empty())
}
impl From<RawCanister> for CanisterInfo {
fn from(r: RawCanister) -> Self {
let latest_upgrade_proposal = r
.upgrades
.unwrap_or_default()
.into_iter()
.max_by_key(|u| u.executed_timestamp_seconds)
.and_then(|u| u.proposal_id);
CanisterInfo {
canister_id: r.canister_id,
name: non_empty(r.name),
canister_type: non_empty(r.canister_type),
controllers: r.controllers,
subnet_id: non_empty(r.subnet_id),
module_hash: non_empty(r.module_hash),
language: non_empty(r.language),
latest_upgrade_proposal,
}
}
}
pub async fn lookup_canister(client: &reqwest::Client, id: &str) -> Result<CanisterInfo, String> {
if Principal::from_text(id).is_err() {
return Err(format!("invalid canister id: {id}"));
}
let url = format!("{}/api/v3/canisters/{id}", dashboard_api());
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("dashboard request failed: {e}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(CanisterInfo {
canister_id: id.to_string(),
..Default::default()
});
}
if !resp.status().is_success() {
return Err(format!(
"dashboard returned HTTP {} for {id}",
resp.status().as_u16()
));
}
let body = resp
.text()
.await
.map_err(|e| format!("could not read dashboard response: {e}"))?;
let raw: RawCanister = serde_json::from_str(&body)
.map_err(|e| format!("could not parse dashboard response: {e}"))?;
Ok(raw.into())
}
#[derive(Serialize, Clone, Debug)]
pub struct Match {
pub canister_id: String,
pub name: String,
pub kind: String,
pub note: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct FoundCanister {
pub canister_id: String,
pub name: String,
pub kind: String,
pub note: Option<String>,
}
impl From<&Match> for FoundCanister {
fn from(m: &Match) -> Self {
Self {
canister_id: m.canister_id.clone(),
name: m.name.clone(),
kind: m.kind.clone(),
note: m.note.clone(),
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindCanisterArgs {
pub query: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct FindCanisterOutput {
pub query: String,
pub matches: Vec<FoundCanister>,
}
impl From<(String, Vec<Match>)> for FindCanisterOutput {
fn from((query, matches): (String, Vec<Match>)) -> Self {
Self {
query,
matches: matches.iter().map(FoundCanister::from).collect(),
}
}
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindAppArgs {
pub name: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct AppMatch {
pub name: String,
pub app_url: String,
pub derivation_origin: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct FindAppOutput {
pub query: String,
pub matches: Vec<AppMatch>,
pub note: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct OpenAppArgs {
pub app: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct OpenAppOutput {
pub app_url: String,
pub app_url_source: String,
pub application_origin: String,
pub derivation_origin: String,
pub derivation_origin_source: String,
pub alternative_origins: Vec<String>,
pub application_is_ic: Option<bool>,
pub canisters: Vec<DiscoveredCanister>,
pub omitted: usize,
pub discovery_error: Option<String>,
pub note: Option<String>,
}
pub fn find_app_by_name(query: &str) -> FindAppOutput {
match find_known_app(query) {
Some(app) => {
let derivation_origin = known_app_derivation_origin(app);
FindAppOutput {
query: query.to_string(),
matches: vec![AppMatch {
name: app.name.to_string(),
app_url: app.app_url.to_string(),
derivation_origin: derivation_origin.to_string(),
}],
note: format!(
"Well-known app. Use discover_app_canisters(\"{}\") to find its canisters; its \
derivation origin is already {} (no resolve_app needed).",
app.app_url, derivation_origin
),
}
}
None => {
let known = KNOWN_APPS.iter().map(|a| a.name).collect::<Vec<_>>().join(", ");
FindAppOutput {
query: query.to_string(),
matches: Vec::new(),
note: format!(
"\"{query}\" is not in the connector's small built-in set of well-known apps \
({known}). There is no on-chain directory mapping an app NAME to its URL, so \
do a WEB SEARCH for the app's official front-end URL (or ask the user for it), \
then call resolve_app / discover_app_canisters with that URL. Do NOT guess or \
fabricate a domain from the name — a lookalike domain (e.g. <name>.com/.app) is \
typically an unrelated or squatted site, and an identity derived there would be \
wrong."
),
}
}
}
}
#[derive(Deserialize)]
struct LedgersResp {
#[serde(default)]
data: Vec<RawLedger>,
}
#[derive(Deserialize)]
struct RawLedger {
ledger_canister_id: String,
#[serde(default)]
sns_root_canister_id: Option<String>,
#[serde(default)]
icrc1_metadata: LedgerMeta,
}
#[derive(Deserialize, Default)]
struct LedgerMeta {
#[serde(default)]
icrc1_name: Option<String>,
#[serde(default)]
icrc1_symbol: Option<String>,
}
#[derive(Deserialize)]
struct SnsesResp {
#[serde(default)]
data: Vec<RawSns>,
}
#[derive(Deserialize)]
struct RawSns {
root_canister_id: String,
#[serde(default)]
name: Option<String>,
}
pub async fn search_by_name(query: &str) -> Result<Vec<Match>, String> {
if query.trim().is_empty() {
return Ok(Vec::new());
}
let client = http_client()?;
let ledgers_url = format!("{}/api/v1/ledgers?limit=100", icrc_api());
let snses_url = format!("{}/api/v1/snses?limit=100&offset=0", sns_api());
let (ledgers, snses) = tokio::join!(
fetch_text(&client, &ledgers_url),
fetch_text(&client, &snses_url),
);
if ledgers.is_err() && snses.is_err() {
return Err(ledgers
.err()
.or(snses.err())
.unwrap_or_else(|| "search failed".into()));
}
Ok(search_in(
ledgers.as_deref().unwrap_or("{}"),
snses.as_deref().unwrap_or("{}"),
query,
))
}
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String, String> {
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("request to {url} failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{url} returned HTTP {}", resp.status().as_u16()));
}
resp.text().await.map_err(|e| format!("reading {url}: {e}"))
}
fn search_in(ledgers_json: &str, snses_json: &str, query: &str) -> Vec<Match> {
let q = query.trim().to_lowercase();
let mut out = Vec::new();
if q.is_empty() {
return out;
}
if let Ok(resp) = serde_json::from_str::<LedgersResp>(ledgers_json) {
for l in resp.data {
let symbol = l.icrc1_metadata.icrc1_symbol.unwrap_or_default();
let name = l.icrc1_metadata.icrc1_name.unwrap_or_default();
if symbol.to_lowercase().contains(&q) || name.to_lowercase().contains(&q) {
let display = if symbol.is_empty() {
name.clone()
} else if name.is_empty() || name == symbol {
symbol.clone()
} else {
format!("{name} ({symbol})")
};
let note = match l.sns_root_canister_id {
Some(r) => format!("ICRC token ledger; SNS root {r}"),
None => "ICRC token ledger".into(),
};
out.push(Match {
canister_id: l.ledger_canister_id,
name: display,
kind: "token".into(),
note: Some(note),
});
}
}
}
if let Ok(resp) = serde_json::from_str::<SnsesResp>(snses_json) {
for s in resp.data {
let name = s.name.unwrap_or_default();
if name.to_lowercase().contains(&q) {
out.push(Match {
canister_id: s.root_canister_id,
name,
kind: "sns".into(),
note: Some(
"SNS project root — icp_lookup_canister_info_by_id (or the SNS detail API) expands it \
to governance/ledger/swap/index"
.into(),
),
});
}
}
}
out.truncate(25);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ip_globality_classification() {
let g = |s: &str| ip_is_global(&s.parse::<IpAddr>().unwrap());
assert!(g("8.8.8.8"));
assert!(g("1.1.1.1"));
assert!(g("2606:4700:4700::1111"));
for bad in [
"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254",
"100.64.0.1", "0.0.0.0", "255.255.255.255", "192.0.2.1", "198.18.0.1", "240.0.0.1",
"::1", "::", "fc00::1", "fd12::1", "fe80::1", "2001:db8::1",
"::ffff:127.0.0.1", "::ffff:10.0.0.1", "::127.0.0.1", "::192.168.1.1", "64:ff9b::a9fe:a9fe", "64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b:1::a9fe:a9fe", "2002:a9fe:a9fe::", "2002:7f00:1::", "2001:0:0:0:0:0:a9fe:a9fe", ] {
assert!(!g(bad), "{bad} must be classified non-global");
}
assert!(g("2001:4860:4860::8888"));
}
#[tokio::test]
async fn resolve_public_url_rejects_ssrf_targets() {
for bad in [
"http://169.254.169.254/latest/meta-data/", "http://127.0.0.1:6379/", "https://127.0.0.1:6379/", "https://10.0.0.1/", "https://169.254.169.254/", "https://[::1]/", "ftp://example.com/", ] {
assert!(resolve_public_url(bad).await.is_err(), "{bad} must be refused");
}
let (_, addrs) = resolve_public_url("https://8.8.8.8/").await.expect("public ip ok");
assert_eq!(addrs, vec!["8.8.8.8:443".parse().unwrap()]);
}
#[test]
fn redirect_hop_policy() {
let u = |s: &str| url::Url::parse(s).unwrap();
assert!(redirect_hop_ok(&u("https://oisy.com/app"), Some("oisy.com")));
assert!(!redirect_hop_ok(&u("https://evil.example/x"), Some("oisy.com")));
assert!(redirect_hop_ok(&u("https://8.8.8.8/x"), Some("oisy.com")));
assert!(!redirect_hop_ok(&u("https://127.0.0.1/x"), Some("oisy.com")));
assert!(!redirect_hop_ok(&u("https://169.254.169.254/x"), Some("oisy.com")));
assert!(!redirect_hop_ok(&u("https://[::1]/x"), Some("oisy.com")));
assert!(!redirect_hop_ok(&u("http://oisy.com/x"), Some("oisy.com")));
}
#[test]
fn bound_findings_caps_bare_literals_and_reports_omitted() {
let mk = |id: usize, label: Option<&str>, source: &str| Found {
canister_id: format!("id-{id:03}"),
label: label.map(String::from),
sources: vec![source.to_string()],
name: None,
kind: None,
};
let mut found = vec![
mk(0, Some("main backend (App Connect)"), "ai-connect.html"),
mk(1, Some("frontend"), "header"),
mk(2, Some("IC_BACKEND_CANISTER_ID"), "bundle:IC_BACKEND_CANISTER_ID"),
];
found.extend((3..40).map(|i| mk(i, None, "bundle")));
let d = bound_findings(found, 0);
assert_eq!(d.canisters.len(), 23, "3 labelled + 20 bare");
assert_eq!(d.omitted, 17);
assert!(d.canisters.iter().take(3).all(|f| f.label.is_some()), "labelled entries survive");
assert_eq!(d.canisters[3].canister_id, "id-003");
assert_eq!(d.canisters[22].canister_id, "id-022");
let many_labelled: Vec<Found> = (0..60).map(|i| mk(i, Some("x"), "ic-app.json")).collect();
let d = bound_findings(many_labelled, 0);
assert_eq!(d.canisters.len(), 50);
assert_eq!(d.omitted, 10);
let d = bound_findings(vec![mk(0, Some("frontend"), "header")], 0);
assert_eq!((d.canisters.len(), d.omitted), (1, 0));
}
fn valid_principal(i: usize) -> String {
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&(i as u64).to_le_bytes());
Principal::from_slice(&bytes).to_text()
}
#[test]
fn findings_caps_distinct_ids_and_reports_every_omission() {
let overflow = 500;
let n = Findings::MAX + overflow;
let mut found = Findings::default();
for i in 0..n {
found.add(&valid_principal(i), None, "bundle".into());
}
assert_eq!(found.map.len(), Findings::MAX, "distinct ids are capped at MAX");
assert_eq!(found.dropped, overflow, "each once-seen overflow id is counted, not allocated");
let dropped = found.dropped;
let d = bound_findings(found.map.into_values().collect(), dropped);
assert_eq!(d.canisters.len(), 20, "bare-bundle tier capped to 20");
assert_eq!(d.omitted, n - 20, "every one of the {n} ids beyond the 20 kept is reported");
}
#[test]
fn findings_overflow_is_bounded_under_repeats() {
let mut found = Findings::default();
for i in 0..Findings::MAX {
found.add(&valid_principal(i), None, "bundle".into());
}
let overflow = valid_principal(Findings::MAX);
for _ in 0..10_000 {
found.add(&overflow, None, "bundle".into());
}
assert_eq!(found.map.len(), Findings::MAX, "memory stays capped under repeats");
assert_eq!(found.dropped, 10_000);
assert!(!found.map.contains_key(&overflow), "the overflow id is never stored");
}
#[test]
fn findings_caps_sources_per_id() {
let id = valid_principal(0);
let mut found = Findings::default();
for i in 0..1000 {
found.add(&id, None, format!("bundle:LABEL_{i}"));
}
assert_eq!(found.map.len(), 1, "still one distinct id");
assert_eq!(found.map[&id].sources.len(), Findings::MAX_SOURCES, "provenance is capped");
}
#[tokio::test]
async fn discovers_oisy_frontend_and_backend() {
let mut ids: Vec<String> = Vec::new();
for attempt in 1..=3 {
let found = discover("oisy.com").await.expect("discover");
ids = found.canisters.iter().map(|f| f.canister_id.clone()).collect();
if ids.iter().any(|i| i == "cha4i-riaaa-aaaan-qeccq-cai")
&& ids.iter().any(|i| i == "doked-biaaa-aaaar-qag2a-cai")
{
return;
}
eprintln!("attempt {attempt}: incomplete discovery, retrying: {ids:?}");
}
assert!(
ids.iter().any(|i| i == "cha4i-riaaa-aaaan-qeccq-cai"),
"frontend not found: {ids:?}"
);
assert!(
ids.iter().any(|i| i == "doked-biaaa-aaaar-qag2a-cai"),
"backend not found: {ids:?}"
);
}
#[tokio::test]
async fn search_finds_ckusdc_and_lookup_identifies_it() {
let matches = search_by_name("ckUSDC").await.expect("search");
assert!(
matches.iter().any(|m| m.canister_id == "xevnm-gaaaa-aaaar-qafnq-cai"),
"ckUSDC ledger not found: {matches:?}"
);
let client = http_client().expect("client");
let info = lookup_canister(&client, "xevnm-gaaaa-aaaar-qafnq-cai")
.await
.expect("lookup");
assert_eq!(info.canister_type.as_deref(), Some("ledger"));
assert!(info.name.as_deref().unwrap_or_default().contains("ckUSDC"));
}
#[test]
fn resolve_base_falls_back_on_blank_or_unset() {
let default = "https://d.example";
assert_eq!(resolve_base(None, default), default);
assert_eq!(resolve_base(Some("".into()), default), default);
assert_eq!(resolve_base(Some(" ".into()), default), default);
assert_eq!(
resolve_base(Some("https://x.example/".into()), default),
"https://x.example"
);
}
#[tokio::test]
async fn search_by_name_blank_is_empty_without_network() {
assert!(search_by_name(" ").await.unwrap().is_empty());
}
#[test]
fn search_in_matches_token_symbol_and_sns_name() {
let ledgers = r#"{"data":[
{"ledger_canister_id":"xevnm-gaaaa-aaaar-qafnq-cai","sns_root_canister_id":null,
"icrc1_metadata":{"icrc1_name":"ckUSDC","icrc1_symbol":"ckUSDC"}},
{"ledger_canister_id":"ryjl3-tyaaa-aaaaa-aaaba-cai","sns_root_canister_id":null,
"icrc1_metadata":{"icrc1_name":"Internet Computer","icrc1_symbol":"ICP"}}
],"total_ledgers":2}"#;
let snses = r#"{"data":[
{"root_canister_id":"3e3x2-xyaaa-aaaaq-aaala-cai","name":"OpenChat"}
],"total_snses":1}"#;
let usdc = search_in(ledgers, snses, "ckusdc");
assert_eq!(usdc.len(), 1, "{usdc:?}");
assert_eq!(usdc[0].canister_id, "xevnm-gaaaa-aaaar-qafnq-cai");
assert_eq!(usdc[0].kind, "token");
let oc = search_in(ledgers, snses, "openchat");
assert_eq!(oc.len(), 1, "{oc:?}");
assert_eq!(oc[0].canister_id, "3e3x2-xyaaa-aaaaq-aaala-cai");
assert_eq!(oc[0].kind, "sns");
assert!(search_in(ledgers, snses, "icp")
.iter()
.any(|m| m.canister_id == "ryjl3-tyaaa-aaaaa-aaaba-cai"));
assert!(search_in(ledgers, snses, " ").is_empty());
}
#[test]
fn raw_canister_normalises_and_picks_latest_upgrade() {
let json = r#"{"canister_id":"ryjl3-tyaaa-aaaaa-aaaba-cai","name":"ICP Ledger",
"canister_type":"ledger","controllers":["r7inp-6aaaa-aaaaa-aaabq-cai"],
"subnet_id":"tdb26-jop6k","module_hash":"51f4be","language":"",
"upgrades":[{"executed_timestamp_seconds":100,"proposal_id":3},
{"executed_timestamp_seconds":200,"proposal_id":42}]}"#;
let raw: RawCanister = serde_json::from_str(json).unwrap();
let info: CanisterInfo = raw.into();
assert_eq!(info.name.as_deref(), Some("ICP Ledger"));
assert_eq!(info.canister_type.as_deref(), Some("ledger"));
assert_eq!(info.language, None); assert_eq!(info.latest_upgrade_proposal, Some(42)); }
#[test]
fn env_json_yields_backend_canister_id() {
let body = r#"{"backend_canister_id":"dmp3l-2yaaa-aaaae-aamva-cai",
"backend_host":"https://icp-api.io",
"project_id":"019ed114-d95a-71aa-bb1f-2410200446d2"}"#;
let got = canisters_from_env_json(body);
assert_eq!(got.len(), 1, "only the canister key should match: {got:?}");
assert_eq!(got[0].0, "dmp3l-2yaaa-aaaae-aamva-cai");
assert_eq!(got[0].1, "backend_canister_id");
}
#[test]
fn parse_meta_reads_app_connect_canister_id() {
let page = r#"<!doctype html><html><head><meta charset="utf-8">
<meta name="ic:canister-id" content="dmp3l-2yaaa-aaaae-aamva-cai">
<title>Connect</title></head><body></body></html>"#;
assert_eq!(
parse_meta(page, "ic:canister-id").as_deref(),
Some("dmp3l-2yaaa-aaaae-aamva-cai")
);
let flipped = r#"<meta content='aaaaa-aa' name='ic:canister-id'>"#;
assert_eq!(parse_meta(flipped, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
let other = r#"<meta name="viewport" content="width=device-width">"#;
assert_eq!(parse_meta(other, "ic:canister-id"), None);
assert_eq!(parse_meta("<html>no metas</html>", "ic:canister-id"), None);
let multi = format!("{other}\n<meta name=\"ic:network\" content=\"ic\">\n{flipped}");
assert_eq!(parse_meta(&multi, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
assert_eq!(parse_meta(&multi, "ic:network").as_deref(), Some("ic"));
let trap = r#"<meta data-name="ic:canister-id" content="evil-id">"#;
assert_eq!(parse_meta(trap, "ic:canister-id"), None, "data-name must not match name");
let spaced = r#"<meta name = "ic:canister-id" content = "aaaaa-aa">"#;
assert_eq!(parse_meta(spaced, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
let both = r#"<meta data-name="decoy" name="ic:canister-id" content="aaaaa-aa">"#;
assert_eq!(parse_meta(both, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
assert_eq!(attr("name=bare content=\"x\"", "name"), None);
let metadata = r#"<metadata name="ic:canister-id" content="evil-id">"#;
assert_eq!(parse_meta(metadata, "ic:canister-id"), None, "<metadata> must not match");
let after = format!("{metadata}\n<meta name=\"ic:canister-id\" content=\"aaaaa-aa\">");
assert_eq!(parse_meta(&after, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
assert_eq!(parse_meta("<meta name=\"ic:canister-id\" content=\"x\"", "ic:canister-id"), None);
let embedded = r#"<meta data="junk name='ic:canister-id' content='evil'" name="viewport" content="w">"#;
assert_eq!(parse_meta(embedded, "ic:canister-id"), None, "key inside a value must not match");
assert_eq!(attr(r#"data="x name='inner' y" name="real""#, "name").as_deref(), Some("real"));
let upper = r#"<META NAME="ic:canister-id" CONTENT="aaaaa-aa">"#;
assert_eq!(parse_meta(upper, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
let mixed_decoy = r#"<MetaData name="ic:canister-id" content="evil">"#;
assert_eq!(parse_meta(mixed_decoy, "ic:canister-id"), None, "<MetaData> must not match");
}
#[test]
fn add_keeps_first_label_so_declared_probes_run_first() {
let mut found = Findings::default();
let id = "dmp3l-2yaaa-aaaae-aamva-cai";
found.add(id, Some("main backend (App Connect)".into()), "ai-connect.html".into());
found.add(id, Some("frontend".into()), "header".into());
let f = &found.map[id];
assert_eq!(f.label.as_deref(), Some("main backend (App Connect)"));
assert_eq!(f.sources, vec!["ai-connect.html", "header"], "both provenances kept");
}
#[test]
fn app_manifest_parses_and_fails_closed() {
let manifest = r#"{
"name": "Example DEX",
"canisters": [
{"id": "dmp3l-2yaaa-aaaae-aamva-cai", "role": "backend", "description": "orders API"},
{"id": "ryjl3-tyaaa-aaaaa-aaaba-cai", "role": "ledger"},
{"id": "qoctq-giaaa-aaaaa-aaaea-cai", "description": "governance"},
{"id": "aaaaa-aa", "future_field": {"nested": true}},
{"id": " "},
{"role": "orphan-no-id"}
]
}"#;
let got = canisters_from_app_manifest(manifest);
assert_eq!(got.len(), 4, "blank/missing ids are skipped: {got:?}");
assert_eq!(got[0], ("dmp3l-2yaaa-aaaae-aamva-cai".into(), Some("backend — orders API".into())));
assert_eq!(got[1], ("ryjl3-tyaaa-aaaaa-aaaba-cai".into(), Some("ledger".into())));
assert_eq!(got[2], ("qoctq-giaaa-aaaaa-aaaea-cai".into(), Some("governance".into())));
assert_eq!(got[3], ("aaaaa-aa".into(), None), "unknown fields are ignored");
assert!(canisters_from_app_manifest("<!doctype html><html>app</html>").is_empty());
assert!(canisters_from_app_manifest("[1,2,3]").is_empty());
assert!(canisters_from_app_manifest("").is_empty());
let huge = format!(
r#"{{"canisters":[{}]}}"#,
std::iter::repeat(r#"{"id":"aaaaa-aa"}"#)
.take(500)
.collect::<Vec<_>>()
.join(",")
);
assert_eq!(canisters_from_app_manifest(&huge).len(), MAX_MANIFEST_CANISTERS);
let sneaky = r#"{"canisters":[{"id":"aaaaa-aa","role":"ok\u001b[31mEVIL\r\nline"}]}"#;
let got = canisters_from_app_manifest(sneaky);
let label = got[0].1.as_deref().unwrap();
assert!(!label.chars().any(char::is_control), "control chars must be gone: {label:?}");
let long = format!(r#"{{"canisters":[{{"id":"aaaaa-aa","role":"{}"}}]}}"#, "x".repeat(1000));
assert!(canisters_from_app_manifest(&long)[0].1.as_deref().unwrap().len() <= 120);
}
#[test]
fn declared_derivation_origin_parses_and_fails_closed() {
let declared = r#"{"derivation_origin":"https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io","canisters":[]}"#;
assert_eq!(
declared_derivation_origin(declared).as_deref(),
Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io")
);
let with_path = r#"{"derivation_origin":"https://app.example.com/x?y=1"}"#;
assert_eq!(declared_derivation_origin(with_path).as_deref(), Some("https://app.example.com"));
let bare = r#"{"derivation_origin":"app.example.com"}"#;
assert_eq!(declared_derivation_origin(bare).as_deref(), Some("https://app.example.com"));
assert_eq!(declared_derivation_origin(r#"{"canisters":[]}"#), None);
assert_eq!(declared_derivation_origin(r#"{"derivation_origin":" "}"#), None);
assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"ftp://x/"}"#), None);
assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"http://example.com"}"#), None);
assert_eq!(declared_derivation_origin(r#"{"derivation_origin":"https://u:p@example.com"}"#), None);
assert_eq!(declared_derivation_origin("<!doctype html>"), None);
}
#[test]
fn parse_alternative_origins_reads_the_list() {
let body = r#"{"alternativeOrigins":["https://multidex.ai","https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io"]}"#;
assert_eq!(
parse_alternative_origins(body),
vec!["https://multidex.ai", "https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io"]
);
assert!(parse_alternative_origins("<!doctype html>").is_empty());
assert!(parse_alternative_origins(r#"{"other":1}"#).is_empty());
assert_eq!(
parse_alternative_origins(
r#"{"alternativeOrigins":["https://a.example/some/path","http://insecure.example","ftp://x","not a url"]}"#
),
vec!["https://a.example"]
);
}
#[test]
fn derivation_origin_authorized_requires_the_declared_origin_to_list_the_app() {
use super::derivation_origin_authorized;
let app = "https://attacker.example";
assert!(derivation_origin_authorized(app, app, &[]));
assert!(!derivation_origin_authorized(
app,
"https://oisy.com",
&["https://oisy.com".into(), "https://nns.ic0.app".into()]
));
assert!(!derivation_origin_authorized(app, "https://oisy.com", &[]));
assert!(derivation_origin_authorized(
"https://frontend.example",
"https://oisy.com",
&["https://frontend.example".into()]
));
assert!(!derivation_origin_authorized(
"https://app.example",
"https://oisy.com",
&["https://app.example:8443".into(), "http://app.example".into(), "https://app.example.evil".into()]
));
}
#[test]
fn decide_declared_origin_authorizes_or_errors() {
use super::{decide_declared_origin, DerivationSource};
let app = "https://app.example";
assert_eq!(
decide_declared_origin(app, None, &[]).unwrap(),
(app.to_string(), DerivationSource::AppUrlDefault)
);
assert_eq!(
decide_declared_origin(app, Some(app), &[]).unwrap(),
(app.to_string(), DerivationSource::Declared)
);
assert_eq!(
decide_declared_origin(app, Some("https://oisy.com"), &[app.into()]).unwrap(),
("https://oisy.com".to_string(), DerivationSource::Declared)
);
assert!(decide_declared_origin(app, Some("https://oisy.com"), &["https://oisy.com".into()]).is_err());
assert!(decide_declared_origin(app, Some("https://oisy.com"), &[]).is_err());
}
#[test]
fn normalize_is_scheme_case_insensitive() {
assert_eq!(normalize("example.com"), "https://example.com");
assert_eq!(normalize("https://example.com/"), "https://example.com");
assert_eq!(normalize("HTTPS://example.com"), "HTTPS://example.com");
assert_eq!(
url::Url::parse(&normalize("HTTPS://example.com")).unwrap().scheme(),
"https"
);
}
#[test]
fn known_derivation_origin_maps_special_cased_apps() {
assert_eq!(known_derivation_origin("nns.internetcomputer.org"), Some("https://nns.ic0.app"));
assert_eq!(known_derivation_origin("nns.ic0.app"), Some("https://nns.ic0.app"));
assert_eq!(known_derivation_origin("oisy.com"), Some("https://oisy.com"));
assert_eq!(
known_derivation_origin("multidex.ai"),
Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io")
);
assert_eq!(known_derivation_origin("app.icpswap.com"), Some("https://app.icpswap.com"));
assert_eq!(known_derivation_origin("example.com"), None);
for (host, origin) in KNOWN_DERIVATION_ORIGINS {
assert_eq!(
normalize_origin(origin).as_deref(),
Some(*origin),
"registry origin for {host} must be a canonical bare https origin"
);
}
}
#[test]
fn registry_is_closed_under_its_derivation_origins() {
for (host, origin) in KNOWN_DERIVATION_ORIGINS {
let d_host = url::Url::parse(origin).unwrap().host_str().unwrap().to_ascii_lowercase();
assert_eq!(
known_derivation_origin(&d_host),
Some(*origin),
"derivation origin {origin} (from entry {host}) must itself be a registry key mapping to itself",
);
}
}
#[test]
fn find_app_by_name_resolves_known_apps_and_directs_others() {
for q in ["MULTI/DEX", "multidex", "multi dex", "Use the MULTIDEX app", "Use the MULTI DEX app"] {
let out = find_app_by_name(q);
assert_eq!(out.matches.len(), 1, "{q:?} should match one app");
assert_eq!(out.matches[0].app_url, "https://multidex.ai");
assert_eq!(
out.matches[0].derivation_origin,
known_derivation_origin("multidex.ai").unwrap(),
);
}
assert_eq!(find_app_by_name("Oisy").matches[0].app_url, "https://oisy.com");
assert_eq!(find_app_by_name("nns").matches[0].app_url, "https://nns.internetcomputer.org");
assert_eq!(find_app_by_name("ICPSwap").matches[0].app_url, "https://app.icpswap.com");
for app in KNOWN_APPS {
let host = url::Url::parse(app.app_url).unwrap().host_str().unwrap().to_ascii_lowercase();
assert!(
known_derivation_origin(&host).is_some(),
"{}'s app_url host {host} must be a key in KNOWN_DERIVATION_ORIGINS \
(the derivation origin is derived from it, not stored)",
app.name,
);
assert_eq!(known_app_derivation_origin(app), known_derivation_origin(&host).unwrap());
}
let unknown = find_app_by_name("Totally Unknown DApp");
assert!(unknown.matches.is_empty());
assert!(unknown.note.to_lowercase().contains("web search"), "note: {}", unknown.note);
assert!(find_app_by_name(" / ").matches.is_empty());
for q in ["noisy", "a noisy afternoon", "annoisyance", "icpswapper", "multidexchange"] {
assert!(
find_app_by_name(q).matches.is_empty(),
"{q:?} must not match a known app (substring false positive)",
);
}
}
#[test]
fn ic_gateway_header_requires_valid_principal_value() {
use reqwest::header::{HeaderMap, HeaderValue};
let with = |v: &str| {
let mut h = HeaderMap::new();
h.insert("x-ic-canister-id", HeaderValue::from_str(v).unwrap());
header_is_ic_principal(&h)
};
assert!(with("ryjl3-tyaaa-aaaaa-aaaba-cai"));
assert!(with(" cha4i-riaaa-aaaan-qeccq-cai ")); assert!(with("aaaaa-aa"));
assert!(!with(""));
assert!(!with("true"));
assert!(!with("not-a-principal"));
assert!(!header_is_ic_principal(&HeaderMap::new()));
}
#[test]
fn similar_known_app_repairs_guessed_domains() {
for guess in ["multidex.com", "https://multidex.app", "multi.dex", "www.multidex.io"] {
let m = similar_known_app(guess)
.unwrap_or_else(|| panic!("{guess} should suggest MULTI/DEX"));
assert_eq!(m.name, "MULTI/DEX");
assert_eq!(m.app_url, "https://multidex.ai");
assert_eq!(m.derivation_origin, known_derivation_origin("multidex.ai").unwrap());
}
assert_eq!(similar_known_app("icpswap.com").map(|m| m.app_url).as_deref(), Some("https://app.icpswap.com"));
assert_eq!(similar_known_app("oisy.org").map(|m| m.name).as_deref(), Some("Oisy"));
for real in ["multidex.ai", "https://oisy.com", "app.icpswap.com", "nns.internetcomputer.org"] {
assert!(similar_known_app(real).is_none(), "{real} is a real host, no suggestion");
}
for other in ["noisy.com", "multidexchange.org", "example.com", ""] {
assert!(similar_known_app(other).is_none(), "{other:?} must not match");
}
}
#[test]
fn classify_app_query_routes_names_urls_and_guesses() {
use AppQuery::*;
let known = |q: &str, want: &str| match classify_app_query(q) {
Known(m) => assert_eq!(m.app_url, want, "{q:?}"),
other => panic!("{q:?} should be a Known match, got {:?}", DebugQ(&other)),
};
known("MULTI/DEX", "https://multidex.ai");
known("multi dex", "https://multidex.ai");
known("multidex.com", "https://multidex.ai"); known("Oisy", "https://oisy.com");
known("nns", "https://nns.internetcomputer.org");
match classify_app_query("https://multidex.com") {
Url(u) => assert_eq!(u, "https://multidex.com"),
other => panic!("explicit scheme must be a Url, got {:?}", DebugQ(&other)),
}
match classify_app_query("coolapp.io") {
Url(u) => assert_eq!(u, "coolapp.io"),
other => panic!("unknown dotted host must be a Url, got {:?}", DebugQ(&other)),
}
assert!(matches!(classify_app_query("totallyunknownapp"), UnknownName));
assert!(matches!(classify_app_query(" "), UnknownName));
}
struct DebugQ<'a>(&'a AppQuery);
impl std::fmt::Debug for DebugQ<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
AppQuery::Known(m) => write!(f, "Known({})", m.app_url),
AppQuery::Url(u) => write!(f, "Url({u})"),
AppQuery::UnknownName => write!(f, "UnknownName"),
}
}
}
#[tokio::test]
async fn resolve_app_identity_flags_non_ic_origin() {
let r = resolve_app_identity("example.com", false).await.expect("resolve");
assert_eq!(r.derivation_origin_source, DerivationSource::AppUrlDefault);
assert_eq!(r.application_is_ic, Some(false), "example.com must show no IC evidence");
}
#[tokio::test]
async fn resolve_app_identity_skips_probe_for_known_apps() {
let r = resolve_app_identity("oisy.com", false).await.expect("resolve");
assert_eq!(r.derivation_origin_source, DerivationSource::Known);
assert_eq!(r.application_is_ic, None, "known apps are not probed");
}
#[tokio::test]
async fn known_apps_are_closed_over_their_alt_origins() {
for d in [
"https://nns.ic0.app",
"https://oisy.com",
"https://hcv4s-uaaaa-aaabq-qaaba-cai.icp0.io",
] {
let d_host = url::Url::parse(d).unwrap().host_str().unwrap().to_ascii_lowercase();
let expected = known_derivation_origin(&d_host)
.unwrap_or_else(|| panic!("{d_host} must be a registry key"));
let alts = fetch_alternative_origins(d).await;
if alts.is_empty() {
continue; }
for o in &alts {
let h = url::Url::parse(o).unwrap().host_str().unwrap().to_ascii_lowercase();
assert_eq!(
known_derivation_origin(&h),
Some(expected),
"alt-origin {o} of {d} is not mapped to {expected} in the registry (drift?)",
);
}
}
}
#[test]
fn is_app_data_candidate_scopes_to_app_backends() {
let dc = |label: Option<&str>, sources: &[&str], kind: Option<&str>| DiscoveredCanister {
canister_id: "aaaaa-aa".to_string(),
label: label.map(str::to_string),
name: None,
kind: kind.map(str::to_string),
sources: sources.iter().map(|s| s.to_string()).collect(),
oql: None,
api_doc_available: None,
};
assert!(is_app_data_candidate(&dc(None, &["ai-connect.html"], None)));
assert!(is_app_data_candidate(&dc(Some("backend"), &["ic-app.json"], None)));
assert!(is_app_data_candidate(&dc(Some("backend_canister_id"), &["env.json"], None)));
assert!(is_app_data_candidate(&dc(Some("BACKEND"), &["bundle:BACKEND"], None)));
assert!(!is_app_data_candidate(&dc(Some("frontend"), &["ic-app.json"], None)));
assert!(!is_app_data_candidate(&dc(None, &["header"], None)));
assert!(!is_app_data_candidate(&dc(None, &["bundle"], Some("ledger"))));
assert!(!is_app_data_candidate(&dc(None, &["ic-app.json"], Some("governance"))));
}
}