use std::io::Read;
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use crate::registry;
use crate::registry::sha256;
const DEFAULT_HOST: &str = "https://raw.githubusercontent.com/Entelligentsia/grove-registry/v1";
#[derive(Deserialize)]
struct Catalog {
#[serde(default)]
release_base: Option<String>,
#[serde(default)]
grammars: Vec<CatEntry>,
}
#[derive(Deserialize)]
struct CatEntry {
name: String,
version: String,
#[serde(default)]
extensions: Vec<String>,
#[serde(default)]
files: std::collections::HashMap<String, FileRef>,
}
pub struct CatalogGrammar {
pub name: String,
pub extensions: Vec<String>,
}
pub fn catalog_grammars() -> Result<Vec<CatalogGrammar>> {
let host = host();
let catalog: Catalog = serde_json::from_slice(&get_bytes(&format!("{host}/index.json"))?)
.context("parsing index.json catalog")?;
Ok(catalog
.grammars
.into_iter()
.map(|g| CatalogGrammar { name: g.name, extensions: g.extensions })
.collect())
}
#[derive(Deserialize)]
struct FileRef {
sha256: String,
#[serde(default)]
asset: Option<String>,
}
fn host() -> String {
std::env::var("GROVE_REGISTRY_URL")
.unwrap_or_else(|_| DEFAULT_HOST.to_string())
.trim_end_matches('/')
.to_string()
}
pub(crate) fn get_bytes(url: &str) -> Result<Vec<u8>> {
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow!("GET {url}: {e}"))?;
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.with_context(|| format!("reading {url}"))?;
Ok(buf)
}
fn safe_segment(s: &str) -> Result<()> {
use std::path::{Component, Path};
let mut comps = Path::new(s).components();
let single_normal =
matches!(comps.next(), Some(Component::Normal(_))) && comps.next().is_none();
if s.is_empty() || s.contains('/') || s.contains('\\') || !single_normal {
bail!("catalog path segment `{s}` is not a plain file name (possible path traversal)");
}
Ok(())
}
pub fn run(langs: &[String], force: bool) -> Result<()> {
let host = host();
println!("registry host: {host}\n");
let catalog: Catalog = serde_json::from_slice(&get_bytes(&format!("{host}/index.json"))?)
.context("parsing index.json catalog")?;
let targets: Vec<&CatEntry> = if langs.is_empty() {
catalog.grammars.iter().collect()
} else {
langs
.iter()
.map(|l| {
catalog
.grammars
.iter()
.find(|g| &g.name == l)
.with_context(|| format!("`{l}` is not in the catalog at {host}"))
})
.collect::<Result<_>>()?
};
let cache = registry::cache_root().context("no OS cache directory available")?;
let mut fetched = 0;
for e in targets {
safe_segment(&e.name)?;
let dir = cache.join(&e.name);
if dir.join("grammar.wasm").exists() && !force {
println!(" {:<12} {} · cached", e.name, e.version);
continue;
}
if e.files.is_empty() {
bail!("catalog entry for `{}` lists no files", e.name);
}
let mut names: Vec<&String> = e.files.keys().collect();
names.sort();
let mut blobs = Vec::new();
for fname in names {
safe_segment(fname)?;
let fref = &e.files[fname];
let url = match &fref.asset {
Some(asset) => {
let base = catalog
.release_base
.as_deref()
.context("catalog has release assets but no release_base")?;
format!("{base}/{asset}")
}
None => format!("{host}/{}/{fname}", e.name),
};
let bytes = get_bytes(&url)?;
let got = sha256(&bytes);
if got != fref.sha256 {
bail!("{}/{fname}: hash mismatch — catalog {}, downloaded {got}", e.name, fref.sha256);
}
blobs.push((fname.clone(), bytes));
}
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
for (fname, bytes) in &blobs {
std::fs::write(dir.join(fname), bytes)?;
}
let kb = blobs
.iter()
.find(|(n, _)| n == "grammar.wasm")
.map_or(0, |(_, b)| b.len() / 1024);
println!(" {:<12} {} ✓ {} files, {} KB", e.name, e.version, blobs.len(), kb);
fetched += 1;
}
println!("\n{fetched} fetched · cache: {}", cache.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::{host, safe_segment, sha256, Catalog, DEFAULT_HOST};
#[test]
fn host_defaults_and_honors_env_override() {
std::env::remove_var("GROVE_REGISTRY_URL");
assert_eq!(host(), DEFAULT_HOST, "defaults to the hosted registry");
std::env::set_var("GROVE_REGISTRY_URL", "https://mirror.test/grove/");
assert_eq!(host(), "https://mirror.test/grove", "override + trailing slash trimmed");
std::env::remove_var("GROVE_REGISTRY_URL");
}
#[test]
fn catalog_parses_schema_2_with_release_assets() {
let json = r#"{
"schema": 2,
"release_base": "https://example.test/releases/v1",
"grammars": [
{ "name": "rust", "version": "0.24.0", "extensions": ["rs"],
"files": {
"grammar.wasm": { "sha256": "sha256:aa", "asset": "rust.wasm" },
"tags.scm": { "sha256": "sha256:bb" }
} }
]
}"#;
let cat: Catalog = serde_json::from_str(json).unwrap();
assert_eq!(cat.release_base.as_deref(), Some("https://example.test/releases/v1"));
assert_eq!(cat.grammars.len(), 1);
let g = &cat.grammars[0];
assert_eq!(g.name, "rust");
assert_eq!(g.extensions, vec!["rs"]);
assert_eq!(g.files["grammar.wasm"].asset.as_deref(), Some("rust.wasm"));
assert_eq!(g.files["grammar.wasm"].sha256, "sha256:aa");
assert!(g.files["tags.scm"].asset.is_none(), "repo-served file has no asset");
}
#[test]
fn catalog_tolerates_missing_optional_fields() {
let cat: Catalog = serde_json::from_str(r#"{ "grammars": [] }"#).unwrap();
assert!(cat.release_base.is_none());
assert!(cat.grammars.is_empty());
}
#[test]
fn fetch_verifies_with_the_shared_helper() {
assert_eq!(
sha256(b""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(sha256(b"abc"), crate::registry::sha256(b"abc"));
}
#[test]
fn accepts_plain_file_names() {
for ok in ["rust", "javascript", "c_sharp", "grammar.wasm", "tags.scm", "manifest.json"] {
assert!(safe_segment(ok).is_ok(), "{ok} should be accepted");
}
}
#[test]
fn rejects_traversal_and_separators() {
for bad in [
"",
".",
"..",
"../etc",
"../../.bashrc",
"/etc/passwd",
"a/b",
"a\\b",
"..\\..\\foo",
"foo/",
"./foo",
] {
assert!(safe_segment(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn traversal_segment_does_not_escape_cache() {
let cache = std::path::Path::new("/home/user/.cache/grove/grammars");
let hostile = "../../../.bashrc";
assert!(safe_segment(hostile).is_err());
let joined = cache.join(hostile);
assert!(!joined.starts_with(cache) || joined.components().any(|c| {
matches!(c, std::path::Component::ParentDir)
}));
}
}