use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
const BUILTIN: &str = "pkg";
#[cfg(not(target_arch = "wasm32"))]
const REGISTRY_HINT: &str =
"the registry refused the request or could not be reached; check the network and IRONSHELL_REGISTRY_TOKEN, then retry";
#[cfg(not(target_arch = "wasm32"))]
fn registry_refused(message: String) -> anyhow::Error {
crate::safety::io_fault(BUILTIN, &message, REGISTRY_HINT)
}
#[cfg(not(target_arch = "wasm32"))]
fn registry_absent(name: &str) -> anyhow::Error {
use crate::safety::{ErrorCode, SafetyError};
anyhow::Error::new(SafetyError {
code: ErrorCode::NotFound,
message: format!("package '{name}' not found in registry"),
builtin: BUILTIN.to_string(),
hint: "list the registry's packages with marketplace.search and use a name from it"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "an existing package".to_string(),
got: name.to_string(),
extra: serde_json::Map::new(),
})
}
#[cfg(not(target_arch = "wasm32"))]
fn auth_required() -> anyhow::Error {
use crate::safety::{ErrorCode, SafetyError};
anyhow::Error::new(SafetyError {
code: ErrorCode::BadState,
message: format!("{BUILTIN}: authentication required; no registry token is set"),
builtin: BUILTIN.to_string(),
hint: "set IRONSHELL_REGISTRY_TOKEN or IRONSHELL_TOKEN in the environment, then retry"
.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: "IRONSHELL_REGISTRY_TOKEN or IRONSHELL_TOKEN".to_string(),
got: "neither set".to_string(),
extra: serde_json::Map::new(),
})
}
#[cfg(target_arch = "wasm32")]
fn wasm_unavailable() -> anyhow::Error {
crate::safety::unimplemented(
BUILTIN,
"the package registry is not available in a wasm build",
"use a native build of ish to reach the package registry",
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageManifest {
pub package: PackageInfo,
#[serde(default)]
pub dependencies: HashMap<String, Dependency>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageInfo {
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default = "default_main")]
pub main: String,
}
fn default_main() -> String {
"main.ish".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Dependency {
Version(String),
Detailed(DependencySpec),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencySpec {
pub version: String,
#[serde(default)]
pub git: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone)]
pub struct PackageRegistry {
pub url: String,
pub cache_dir: PathBuf,
}
impl Default for PackageRegistry {
fn default() -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ironshell")
.join("packages");
Self {
url: "https://packages.nervosys.ai".to_string(),
cache_dir,
}
}
}
impl PackageRegistry {
pub fn resolve(&self, package: &str) -> Result<PathBuf> {
let (name, version) = if let Some(idx) = package.find('@') {
(&package[..idx], Some(&package[idx + 1..]))
} else {
(package, None)
};
let pkg_dir = self.cache_dir.join(name);
if let Some(ver) = version {
let versioned_dir = pkg_dir.join(ver);
if versioned_dir.exists() {
return Ok(versioned_dir);
}
} else if pkg_dir.exists() {
if let Some(latest) = self.find_latest_version(&pkg_dir)? {
return Ok(pkg_dir.join(latest));
}
}
Err(anyhow!(
"package '{}' not found. Install with: ish pkg install {}",
package,
package
))
}
fn find_latest_version(&self, pkg_dir: &Path) -> Result<Option<String>> {
let mut versions: Vec<semver::Version> = Vec::new();
for entry in fs::read_dir(pkg_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Ok(ver) = semver::Version::parse(&entry.file_name().to_string_lossy()) {
versions.push(ver);
}
}
}
versions.sort();
Ok(versions.last().map(|v| v.to_string()))
}
pub fn list_installed(&self) -> Result<Vec<(String, Vec<String>)>> {
let mut packages = Vec::new();
if !self.cache_dir.exists() {
return Ok(packages);
}
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
let mut versions = Vec::new();
for ver_entry in fs::read_dir(entry.path())? {
let ver_entry = ver_entry?;
if ver_entry.file_type()?.is_dir() {
versions.push(ver_entry.file_name().to_string_lossy().to_string());
}
}
if !versions.is_empty() {
packages.push((name, versions));
}
}
}
Ok(packages)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryPackage {
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub downloads: i64,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub checksum: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrySearchResponse {
#[serde(default)]
pub packages: Vec<RegistryPackage>,
#[serde(default)]
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryListResponse {
#[serde(default)]
pub packages: Vec<RegistryPackage>,
#[serde(default)]
pub next_token: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RegistryClient {
pub base_url: String,
pub registry: String,
pub cache_dir: PathBuf,
}
impl Default for RegistryClient {
fn default() -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ironshell")
.join("packages");
Self {
base_url: "https://packages.nervosys.ai".to_string(),
registry: "ironshell".to_string(),
cache_dir,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
fn check_segment(s: &str, what: &str) -> Result<()> {
let ok = !s.is_empty()
&& s != "."
&& s != ".."
&& s.len() <= 128
&& !s
.chars()
.any(|c| matches!(c, '/' | '\\' | ':') || c.is_control());
if ok {
Ok(())
} else {
Err(crate::safety::bad_arg(
BUILTIN,
&format!("a {what} that is one safe path segment"),
&format!("{s:?}"),
))
}
}
pub(crate) fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub(crate) fn sha256_matches(bytes: &[u8], expected: &str) -> Result<bool> {
use ironcrypto::core_types::traits::Digest;
let digest = ironcrypto::hash::Sha256::digest(bytes)
.map_err(|e| crate::safety::ic_failed("marketplace install", e))?;
Ok(ironcrypto::core_types::ct::verify(
hex::encode(digest).as_bytes(),
expected.to_ascii_lowercase().as_bytes(),
))
}
#[cfg(not(target_arch = "wasm32"))]
fn read_body_limited(resp: reqwest::blocking::Response, max: u64) -> Result<Vec<u8>> {
use std::io::Read;
let mut buf = Vec::new();
resp.take(max + 1)
.read_to_end(&mut buf)
.context("failed to read registry response")?;
if buf.len() as u64 > max {
return Err(crate::safety::budget_exceeded(
BUILTIN,
format!("registry response is larger than {max} bytes; refusing it"),
"the registry sent more than this client accepts; the response was not buffered",
));
}
Ok(buf)
}
impl RegistryClient {
fn api_url(&self, path: &str) -> String {
format!("{}/{}/api/v1{}", self.base_url, self.registry, path)
}
fn auth_token() -> Option<String> {
std::env::var("IRONSHELL_REGISTRY_TOKEN")
.or_else(|_| std::env::var("IRONSHELL_TOKEN"))
.ok()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn search(&self, query: &str, category: Option<&str>) -> Result<Vec<RegistryPackage>> {
let mut url = self.api_url("/search");
url.push_str(&format!("?q={}", urlencoding::encode(query)));
if let Some(cat) = category {
url.push_str(&format!("&category={}", urlencoding::encode(cat)));
}
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.get(&url)
.header("User-Agent", "IronShell/1.5.0")
.send()
.context("failed to connect to package registry")?;
if resp.status().is_success() {
let body: RegistrySearchResponse =
serde_json::from_slice(&read_body_limited(resp, MAX_JSON_BYTES)?)
.context("failed to parse registry search response")?;
Ok(body.packages)
} else if resp.status().as_u16() == 404 {
Ok(vec![])
} else {
Err(registry_refused(format!(
"registry search failed: HTTP {}",
resp.status()
)))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn list_remote(&self) -> Result<Vec<RegistryPackage>> {
let url = self.api_url("/packages");
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.get(&url)
.header("User-Agent", "IronShell/1.5.0")
.send()
.context("failed to connect to package registry")?;
if resp.status().is_success() {
let body: RegistryListResponse =
serde_json::from_slice(&read_body_limited(resp, MAX_JSON_BYTES)?)
.context("failed to parse registry list response")?;
Ok(body.packages)
} else {
Err(registry_refused(format!(
"registry list failed: HTTP {}",
resp.status()
)))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn get_package(&self, name: &str) -> Result<RegistryPackage> {
let url = self.api_url(&format!("/packages/{}", urlencoding::encode(name)));
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()?;
let resp = client
.get(&url)
.header("User-Agent", "IronShell/1.5.0")
.send()
.context("failed to connect to package registry")?;
if resp.status().is_success() {
let pkg: RegistryPackage =
serde_json::from_slice(&read_body_limited(resp, MAX_JSON_BYTES)?)
.context("failed to parse registry package response")?;
Ok(pkg)
} else if resp.status().as_u16() == 404 {
Err(registry_absent(name))
} else {
Err(registry_refused(format!(
"registry get failed: HTTP {}",
resp.status()
)))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn download_package(
&self,
name: &str,
version: &str,
checksum: Option<&str>,
) -> Result<PathBuf> {
check_segment(name, "package name")?;
check_segment(version, "package version")?;
let expected = match checksum {
Some(d) if is_sha256_hex(d) => d,
Some(d) => {
return Err(crate::safety::policy_deny(
BUILTIN,
format!(
"package {name}@{version} has a digest of {} characters, not a SHA-256 (64 hex digits); refusing to install it",
d.len()
),
"the registry must publish a SHA-256 digest for each package; nothing was downloaded",
))
}
None => {
return Err(crate::safety::policy_deny(
BUILTIN,
format!("package {name}@{version} has no published digest; refusing to install it"),
"a package is installed only with the SHA-256 digest its registry publishes; nothing was downloaded",
))
}
};
let url = self.api_url(&format!(
"/packages/{}/{}/download",
urlencoding::encode(name),
urlencoding::encode(version)
));
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let resp = client
.get(&url)
.header("User-Agent", "IronShell/1.5.0")
.send()
.context("failed to download package from registry")?;
if !resp.status().is_success() {
return Err(registry_refused(format!(
"download failed for {}@{}: HTTP {}",
name,
version,
resp.status()
)));
}
let bytes = read_body_limited(resp, MAX_ARCHIVE_BYTES)?;
if !sha256_matches(&bytes, expected)? {
return Err(crate::safety::policy_deny(
BUILTIN,
format!("package {name}@{version} does not match its published SHA-256 digest; refusing to install it"),
"the download is corrupt or was altered in transit; nothing was written, so retry the install",
));
}
let pkg_dir = self.cache_dir.join(name).join(version);
fs::create_dir_all(&pkg_dir).context("failed to create package cache directory")?;
let tarball_path = pkg_dir.join(format!("{}-{}.tar.gz", name, version));
fs::write(&tarball_path, &bytes).context("failed to write package archive")?;
self.extract_tarball(&tarball_path, &pkg_dir)?;
Ok(pkg_dir)
}
pub fn extract_tarball(&self, tarball: &Path, dest: &Path) -> Result<()> {
let file = fs::File::open(tarball)?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
for entry in archive
.entries()
.context("failed to read package archive")?
{
let mut entry = entry.context("failed to read package archive entry")?;
let kind = entry.header().entry_type();
if kind.is_symlink()
|| kind.is_hard_link()
|| kind.is_character_special()
|| kind.is_block_special()
|| kind.is_fifo()
{
return Err(crate::safety::policy_deny(
BUILTIN,
format!(
"package archive contains a link entry ({}); refusing to unpack it",
entry
.path()
.map(|p| p.display().to_string())
.unwrap_or_default()
),
"a link in a package archive can point outside the cache; the archive was not unpacked",
));
}
let written = entry
.unpack_in(dest)
.context("failed to extract package archive")?;
if !written {
return Err(crate::safety::policy_deny(
BUILTIN,
"package archive has an entry that would leave the cache directory; refusing it",
"the archive names a path outside its own directory; it was not unpacked",
));
}
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn publish(&self, tarball_path: &Path, manifest: &PackageManifest) -> Result<()> {
let token = Self::auth_token().ok_or_else(auth_required)?;
let url = self.api_url("/packages");
let size = fs::metadata(tarball_path)
.context("failed to read package tarball")?
.len();
if size > MAX_ARCHIVE_BYTES {
return Err(crate::safety::bad_arg(
BUILTIN,
&format!("a package archive of at most {MAX_ARCHIVE_BYTES} bytes"),
&format!("{size} bytes"),
));
}
let tarball_bytes = fs::read(tarball_path).context("failed to read package tarball")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&tarball_bytes);
let body = serde_json::json!({
"name": manifest.package.name,
"version": manifest.package.version,
"description": manifest.package.description,
"authors": manifest.package.authors,
"license": manifest.package.license,
"data": encoded,
});
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()?;
let resp = client
.post(&url)
.header("User-Agent", "IronShell/1.5.0")
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.context("failed to publish package to registry")?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let err_body = resp.text().unwrap_or_default();
Err(registry_refused(format!(
"publish failed: HTTP {} — {}",
status, err_body
)))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn yank(&self, name: &str, version: &str) -> Result<()> {
let token = Self::auth_token().ok_or_else(auth_required)?;
let url = self.api_url(&format!(
"/packages/{}/{}",
urlencoding::encode(name),
urlencoding::encode(version)
));
crate::tls::install_provider();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let resp = client
.delete(&url)
.header("User-Agent", "IronShell/1.5.0")
.header("Authorization", format!("Bearer {}", token))
.send()
.context("failed to yank package from registry")?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let err_body = resp.text().unwrap_or_default();
Err(registry_refused(format!(
"yank failed: HTTP {} — {}",
status, err_body
)))
}
}
pub fn package_to_value(pkg: &RegistryPackage) -> crate::value::Value {
let mut rec = BTreeMap::new();
rec.insert(
"name".to_string(),
crate::value::Value::Str(pkg.name.clone()),
);
rec.insert(
"version".to_string(),
crate::value::Value::Str(pkg.version.clone()),
);
rec.insert(
"description".to_string(),
crate::value::Value::Str(pkg.description.clone()),
);
rec.insert(
"author".to_string(),
crate::value::Value::Str(pkg.author.clone()),
);
rec.insert(
"downloads".to_string(),
crate::value::Value::Int(pkg.downloads),
);
rec.insert(
"created_at".to_string(),
crate::value::Value::Str(pkg.created_at.clone()),
);
if let Some(ref cat) = pkg.category {
rec.insert(
"category".to_string(),
crate::value::Value::Str(cat.clone()),
);
}
if !pkg.keywords.is_empty() {
rec.insert(
"keywords".to_string(),
crate::value::Value::Array(
pkg.keywords
.iter()
.map(|k| crate::value::Value::Str(k.clone()))
.collect(),
),
);
}
if let Some(ref lic) = pkg.license {
rec.insert("license".to_string(), crate::value::Value::Str(lic.clone()));
}
crate::value::Value::Record(rec)
}
}
#[cfg(target_arch = "wasm32")]
impl RegistryClient {
pub fn search(&self, _query: &str, _category: Option<&str>) -> Result<Vec<RegistryPackage>> {
Err(wasm_unavailable())
}
pub fn list_remote(&self) -> Result<Vec<RegistryPackage>> {
Err(wasm_unavailable())
}
pub fn get_package(&self, _name: &str) -> Result<RegistryPackage> {
Err(wasm_unavailable())
}
pub fn download_package(&self, _name: &str, _version: &str) -> Result<PathBuf> {
Err(wasm_unavailable())
}
pub fn publish(&self, _tarball_path: &Path, _manifest: &PackageManifest) -> Result<()> {
Err(wasm_unavailable())
}
pub fn yank(&self, _name: &str, _version: &str) -> Result<()> {
Err(wasm_unavailable())
}
}
pub fn parse_version_req(req: &str) -> Result<semver::VersionReq> {
semver::VersionReq::parse(req)
.map_err(|e| anyhow!("invalid version requirement '{}': {}", req, e))
}
pub fn version_satisfies(version: &str, requirement: &str) -> Result<bool> {
let ver = semver::Version::parse(version)
.map_err(|e| anyhow!("invalid version '{}': {}", version, e))?;
let req = parse_version_req(requirement)?;
Ok(req.matches(&ver))
}
pub mod builtins {
use super::*;
use crate::value::Value;
pub fn pkg_list() -> Result<Value> {
let registry = PackageRegistry::default();
let packages = registry.list_installed()?;
let mut result = Vec::new();
for (name, versions) in packages {
let mut pkg_info = BTreeMap::new();
pkg_info.insert("name".to_string(), Value::Str(name));
pkg_info.insert(
"versions".to_string(),
Value::Array(versions.into_iter().map(Value::Str).collect()),
);
result.push(Value::Record(pkg_info));
}
Ok(Value::Array(result))
}
pub fn pkg_info(name: &str) -> Result<Value> {
let registry = PackageRegistry::default();
let pkg_dir = registry.cache_dir.join(name);
if !pkg_dir.exists() {
return Err(anyhow!("package '{}' not installed", name));
}
if let Some(latest) = registry.find_latest_version(&pkg_dir)? {
let manifest_path = pkg_dir.join(&latest).join("ironshell.toml");
if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path)?;
let manifest: PackageManifest = toml::from_str(&content)?;
let mut info = BTreeMap::new();
info.insert("name".to_string(), Value::Str(manifest.package.name));
info.insert("version".to_string(), Value::Str(manifest.package.version));
info.insert(
"description".to_string(),
Value::Str(manifest.package.description),
);
info.insert(
"authors".to_string(),
Value::Array(
manifest
.package
.authors
.into_iter()
.map(Value::Str)
.collect(),
),
);
if let Some(license) = manifest.package.license {
info.insert("license".to_string(), Value::Str(license));
}
if let Some(repo) = manifest.package.repository {
info.insert("repository".to_string(), Value::Str(repo));
}
return Ok(Value::Record(info));
}
}
Err(anyhow!("package manifest not found for '{}'", name))
}
pub fn pkg_cache_dir() -> Value {
let registry = PackageRegistry::default();
Value::Str(registry.cache_dir.display().to_string())
}
pub fn pkg_init(name: &str, description: &str) -> Result<Value> {
let manifest = PackageManifest {
package: PackageInfo {
name: name.to_string(),
version: "0.1.0".to_string(),
description: description.to_string(),
authors: vec![],
license: Some("MIT".to_string()),
repository: None,
main: "main.ish".to_string(),
},
dependencies: HashMap::new(),
};
let toml_content = toml::to_string_pretty(&manifest)?;
fs::write("ironshell.toml", &toml_content)?;
if !Path::new("main.ish").exists() {
fs::write(
"main.ish",
"# IronShell package: {}\n\nprint(\"Hello from {}!\")\n".replace("{}", name),
)?;
}
Ok(Value::Str(format!(
"Created package '{}' with ironshell.toml",
name
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_satisfies() {
assert!(version_satisfies("1.0.0", "^1.0").unwrap());
assert!(version_satisfies("1.2.3", "^1.0").unwrap());
assert!(!version_satisfies("2.0.0", "^1.0").unwrap());
assert!(version_satisfies("1.0.0", ">=1.0.0").unwrap());
assert!(version_satisfies("1.0.0", "1.0.0").unwrap());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn refused_segment_carries_its_code() {
let err = RegistryClient::default()
.download_package("../escape", "1.0.0", None)
.unwrap_err();
let se = err
.downcast_ref::<crate::safety::SafetyError>()
.expect("a coded error");
assert!(matches!(se.code, crate::safety::ErrorCode::BadArg));
assert_eq!(se.builtin, "pkg");
}
#[test]
fn test_parse_manifest() {
let toml = r#"
[package]
name = "my-package"
version = "1.0.0"
description = "A test package"
[dependencies]
utils = "^1.0"
"#;
let manifest: PackageManifest = toml::from_str(toml).unwrap();
assert_eq!(manifest.package.name, "my-package");
assert_eq!(manifest.package.version, "1.0.0");
assert!(manifest.dependencies.contains_key("utils"));
}
}