use super::*;
pub(crate) const OFFICIAL_STATS: &str =
"https://raw.githubusercontent.com/buchochelliq-labs/rs-rich-marketplace/stats/stats.json";
const STATS_SCHEMA_VERSION: u64 = 1;
const MAX_STATS_BYTES: u64 = 4 << 20;
#[cfg(feature = "fetch")]
const STATS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
pub(crate) struct Stats {
pub source: String,
pub generated_at: String,
packages: serde_json::Map<String, Value>,
}
pub(crate) fn stats_source(location: &Location) -> String {
match location {
Location::Local(root) => root.join("stats.json").display().to_string(),
Location::Remote(base) if base == DEFAULT_MARKETPLACE => OFFICIAL_STATS.to_string(),
Location::Remote(base) => match base.strip_suffix("/main/") {
Some(prefix) => format!("{prefix}/stats/stats.json"),
None => format!("{base}stats.json"),
},
}
}
pub(crate) fn load(location: &Location) -> Option<Stats> {
let source = stats_source(location);
let bytes = match location {
Location::Local(root) => read_bounded(&root.join("stats.json"), MAX_STATS_BYTES)
.ok()
.flatten()?,
Location::Remote(_) => fetch_remote(&source)?,
};
parse(&bytes, source)
}
#[cfg(feature = "fetch")]
fn fetch_remote(url: &str) -> Option<Vec<u8>> {
use std::io::Read;
rich_marketplace::require_https(url).ok()?;
let mut response =
rich_marketplace::redirect::get_following(url, 3, true, STATS_TIMEOUT, |hop, deadline| {
rich_marketplace::tls::with_fallback(|trust| {
let config = ureq::Agent::config_builder()
.https_only(true)
.max_redirects(0)
.timeout_global(Some(deadline.remaining()?))
.user_agent("rs-rich-marketplace-stats")
.tls_config(trust.tls_config())
.build();
ureq::Agent::new_with_config(config).get(hop).call()
})
})
.ok()?;
let mut body = Vec::new();
response
.body_mut()
.with_config()
.reader()
.take(MAX_STATS_BYTES + 1)
.read_to_end(&mut body)
.ok()?;
(body.len() as u64 <= MAX_STATS_BYTES).then_some(body)
}
#[cfg(not(feature = "fetch"))]
fn fetch_remote(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(bytes: &[u8], source: String) -> Option<Stats> {
let value: Value = serde_json::from_slice(bytes).ok()?;
if value.get("schema_version")?.as_u64()? != STATS_SCHEMA_VERSION {
return None;
}
Some(Stats {
source,
generated_at: value.get("generated_at")?.as_str()?.to_string(),
packages: value.get("packages")?.as_object()?.clone(),
})
}
fn count(value: Option<&Value>) -> Option<u64> {
value.and_then(Value::as_u64)
}
impl Stats {
pub(crate) fn total(&self, id: &str) -> Option<u64> {
count(self.packages.get(id)?.pointer("/downloads/total"))
}
pub(crate) fn package_json(&self, id: &str) -> Value {
let Some(entry) = self.packages.get(id) else {
return json!({"source": self.source, "generated_at": self.generated_at,
"downloads": Value::Null, "reviews": Value::Null});
};
let versions: serde_json::Map<String, Value> = entry
.pointer("/downloads/versions")
.and_then(Value::as_object)
.map(|versions| {
versions
.iter()
.map(|(version, n)| (line(version), json!(count(Some(n)))))
.collect()
})
.unwrap_or_default();
let reviews = entry.get("reviews").and_then(|r| {
let url = review_link(r.get("url")?.as_str()?)?;
Some(json!({
"url": line(&url),
"up": count(r.get("up")).unwrap_or(0),
"down": count(r.get("down")).unwrap_or(0),
"comments": count(r.get("comments")).unwrap_or(0),
}))
});
json!({
"source": self.source,
"generated_at": self.generated_at,
"downloads": {"total": self.total(id), "versions": versions},
"reviews": reviews,
})
}
pub(crate) fn rows(&self, id: &str, versions: &[String]) -> Vec<(&'static str, String)> {
let value = self.package_json(id);
let mut rows = Vec::new();
if let Some(total) = value.pointer("/downloads/total").and_then(Value::as_u64) {
let counted = value.pointer("/downloads/versions");
let per_version: Vec<String> = versions
.iter()
.map(
|version| match counted.and_then(|c| c.get(version)).and_then(Value::as_u64) {
Some(n) => format!("{version}: {}", thousands(n)),
None => format!("{version}: not counted"),
},
)
.collect();
rows.push((
"Downloads",
format!("{} ({})", thousands(total), per_version.join(", ")),
));
}
if let Some(reviews) = value.get("reviews").filter(|r| !r.is_null()) {
let n = |key: &str| reviews.get(key).and_then(Value::as_u64).unwrap_or(0);
let comments = n("comments");
rows.push((
"Rating",
format!(
"👍 {} 👎 {} ({comments} comment{})",
n("up"),
n("down"),
if comments == 1 { "" } else { "s" }
),
));
if let Some(url) = reviews.get("url").and_then(Value::as_str) {
rows.push(("Reviews", url.to_string()));
}
}
if !rows.is_empty() {
let date = self.generated_at.split('T').next().unwrap_or_default();
rows.push((
"Stats",
format!("as of {}; not signed, shown only", line(date)),
));
}
rows
}
}
const REVIEWS: &str = "https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/";
fn review_link(raw: &str) -> Option<String> {
let lower = raw.to_ascii_lowercase();
if !raw.starts_with(REVIEWS)
|| raw.contains(['?', '#', '\\'])
|| raw.contains("..")
|| lower.contains("%2e%2e")
|| lower.contains(".%2e")
|| lower.contains("%2e.")
|| raw.chars().any(|c| c.is_control() || c.is_whitespace())
{
return None;
}
let normalised = normalise_path(raw);
normalised.starts_with(REVIEWS).then_some(normalised)
}
fn normalise_path(url: &str) -> String {
let Some(rest) = url.strip_prefix("https://") else {
return url.to_string();
};
let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
let mut out: Vec<&str> = Vec::new();
let segments: Vec<&str> = path.split('/').collect();
for (i, segment) in segments.iter().enumerate() {
let last = i + 1 == segments.len();
match segment.to_ascii_lowercase().as_str() {
"." | "%2e" => {
if last {
out.push("");
}
}
".." | ".%2e" | "%2e." | "%2e%2e" => {
out.pop();
if last {
out.push("");
}
}
_ => out.push(segment),
}
}
format!("https://{host}/{}", out.join("/"))
}
fn thousands(n: u64) -> String {
let digits = n.to_string();
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_source_follows_the_marketplace() {
let official = Location::parse(DEFAULT_MARKETPLACE).unwrap();
assert_eq!(stats_source(&official), OFFICIAL_STATS);
let other = Location::parse("https://example.test/market").unwrap();
assert_eq!(
stats_source(&other),
"https://example.test/market/stats.json"
);
}
#[test]
fn counts_are_cleaned() {
let stats = parse(
br#"{"schema_version": 1, "generated_at": "2026-10-27T03:41:07Z",
"packages": {"a": {"downloads": {"total": 1234,
"versions": {"1.0.0": 1234, "0.9.0": null, "0.8.0": -3}},
"reviews": {"url": "https://evil.test/", "up": 1,
"down": 0, "comments": 0}}}}"#,
"here".into(),
)
.unwrap();
let value = stats.package_json("a");
assert_eq!(value["downloads"]["versions"]["0.8.0"], Value::Null);
assert_eq!(value["reviews"], Value::Null);
let versions = ["1.0.0", "0.9.0", "0.8.0"].map(String::from);
assert_eq!(
stats.rows("a", &versions)[0].1,
"1,234 (1.0.0: 1,234, 0.9.0: not counted, 0.8.0: not counted)"
);
assert_eq!(stats.package_json("b")["downloads"], Value::Null);
assert!(parse(br#"{"schema_version": 2}"#, String::new()).is_none());
assert_eq!(thousands(0), "0");
assert_eq!(thousands(999), "999");
assert_eq!(thousands(1000), "1,000");
}
#[test]
fn a_review_link_stays_in_the_marketplaces_discussions() {
let good = "https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7";
assert_eq!(review_link(good).as_deref(), Some(good));
for hostile in [
"https://evil.test/",
"https://github.com/someone-else/rs-rich-marketplace/discussions/7",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/issues/7",
"http://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7",
"https://github.com.evil.test/buchochelliq-labs/rs-rich-marketplace/discussions/7",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/../../evil/repo",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/%2e%2e/%2E%2E/x",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/.%2e/settings",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/%2E./settings",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/..",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7\\..\\..\\x",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7?next=https://evil.test",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7#x",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/?",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7\u{1b}]8;;https://evil.test\u{7}",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7 https://evil.test",
"https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/.\t./x",
" https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/7",
] {
assert_eq!(review_link(hostile), None, "{hostile:?}");
}
assert_eq!(
review_link("https://github.com/buchochelliq-labs/rs-rich-marketplace/discussions/./7")
.as_deref(),
Some(good)
);
assert_eq!(
normalise_path("https://h/a/b/%2e%2e/c/./d/.."),
"https://h/a/c/"
);
}
}