use super::*;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::time::SystemTime;
use rich_ext::plugin_loading::{RuntimeKind, RuntimePlugin};
use rich_marketplace::{
AcceptedIndex, ArtifactChecker, CheckError, FetchError, Host, IndexError, InstallError,
Installer, KeyChainError, KeySet, Kind, LOCKFILE_NAME, ListedVersion, Listing, ListingError,
LockEntry, Lockfile, LockfileError, Request, Requires, ResolveError, Rotation, RotationKind,
Tier, Transport, VerifiedIndex, WasmExpectation, resolve, verify_index,
};
use serde_json::{Value, json};
#[path = "marketplace_publish.rs"]
mod publish;
#[path = "marketplace_stats.rs"]
mod stats;
#[cfg(feature = "interact")]
#[path = "marketplace_browse.rs"]
mod browse;
pub(crate) const DEFAULT_MARKETPLACE: &str =
"https://raw.githubusercontent.com/buchochelliq-labs/rs-rich-marketplace/main/";
const STATE_NAME: &str = "marketplace.state.json";
const STATE_SCHEMA_VERSION: u64 = 1;
const MAX_INDEX_BYTES: u64 = 8 << 20;
const MAX_SIGNATURE_BYTES: u64 = 8 << 10;
const MAX_LISTING_BYTES: u64 = 1 << 20;
const MAX_KEYS_BYTES: u64 = 64 << 10;
const MAX_ROTATIONS: u64 = 64;
const EXIT_DECLINED: u8 = 1;
pub(super) const COMMANDS: &[&str] = &[
"search", "info", "install", "update", "remove", "list", "enable", "disable", "verify",
"browse", "publish",
];
pub(super) fn requested(args: &[String]) -> bool {
subcommand_word(args) == Some("plugin")
}
pub(crate) fn spec() -> rich_ext::cli_doc::CommandSpec {
use rich_ext::cli_doc::{ArgSpec, CommandSpec};
let common = || {
vec![
ArgSpec::option("marketplace").value_name("LOCATION").help(
"The marketplace: an https:// base URL, or a directory or file:// URL laid out as \
the marketplace repository is (artifacts from its artifacts/HOST/PATH mirror, \
never the network); else RICH_MARKETPLACE, else the official one",
),
ArgSpec::option("marketplace-keys").value_name("FILE").help(
"Trust this keys-N.json instead of the keys compiled in; else \
RICH_MARKETPLACE_KEYS",
),
ArgSpec::option("plugin-dir").value_name("DIR").help(
"Install into DIR; else RICH_PLUGIN_DIR, else $XDG_DATA_HOME/rich/plugins \
(~/.local/share/rich/plugins)",
),
ArgSpec::option("report")
.value_name("FORMAT")
.help("json: the result or error envelope on stderr, nothing on stdout"),
]
};
let with_common = |mut command: CommandSpec| {
for arg in common() {
command = command.arg(arg);
}
command
};
let id = || ArgSpec::positional("id").help("The package's id, as `search` shows it");
let ids = || ArgSpec::positional("id").multiple(true).help("Package ids");
let yes = || {
ArgSpec::flag("yes")
.short('y')
.help("Install without asking (for scripts); the summary is still shown")
};
let publish_option =
|name: &str, value: &str, help: &str| ArgSpec::option(name).value_name(value).help(help);
CommandSpec::new("plugin")
.about(
"Find, install, update and check plugins and asset packs from the plugin marketplace: \
a signed index, every file checked by SHA-256; an installed, enabled WASM plugin \
loads on every run without --plugin, after its file is checked against the lockfile",
)
.usage(
"plugin COMMAND [ARGS] [--marketplace LOCATION] [--marketplace-keys FILE] \
[--plugin-dir DIR] [--report json]",
)
.subcommand(with_common(
CommandSpec::new("search")
.about(
"List the marketplace's packages, or those with every word of QUERY in their \
id, name, description, keywords, authors or capabilities; the tier shows as \
`✓ official`, `approved` or `community`, a maintainers' pick as \
`★ recommended`, and who authored each",
)
.usage("search [QUERY] [--kind KIND] [--tier TIER] [--recommended] [--sort id|downloads]")
.arg(ArgSpec::positional("query").help("Words to look for, in any case"))
.arg(
ArgSpec::option("kind")
.value_name("KIND")
.help("Only this kind: crate, wasm-plugin, micro-pack or theme"),
)
.arg(
ArgSpec::option("tier")
.value_name("TIER")
.help(
"Only this tier: official (rs-rich's own), reviewed (shown as \
approved) or community",
),
)
.arg(ArgSpec::flag("recommended").help(
"Only the maintainers' picks, marked `★ recommended`",
))
.arg(ArgSpec::option("sort").value_name("ORDER").help(
"id (the default), or downloads: most downloaded first, from the \
marketplace's unsigned stats.json, with a Downloads column",
))
.example("rich plugin search rot13", "Packages with a rot13 capability, say")
.example("rich plugin search --kind theme --tier official", "rs-rich's own themes"),
))
.subcommand(with_common(
CommandSpec::new("info")
.about("One package: its tier, source, versions, what loads here, for a crate the `cargo add` line, and its downloads, 👍/👎 and reviews link when the marketplace's stats.json is there (not signed, shown only)")
.usage("info ID")
.arg(id()),
))
.subcommand(with_common(
CommandSpec::new("install")
.about(
"Show the tier, permissions and capabilities, ask, then download, check \
(size, SHA-256, the WASM manifest in the sandbox) and install; a crate is \
refused with its `cargo add` line",
)
.usage("install ID[@VERSION]... [--yes]")
.arg(ArgSpec::positional("id").multiple(true).help(
"Package ids, each optionally @VERSION (the only way to get a pre-release)",
))
.arg(yes())
.example("rich plugin install shout", "The newest version this host loads")
.example("rich plugin install shout@0.2.0 --yes", "Exactly 0.2.0, without asking"),
))
.subcommand(with_common(
CommandSpec::new("update")
.about("Move installed packages (or those named) to the newest version this host loads, asking first; a yanked version is replaced")
.usage("update [ID...] [--yes]")
.arg(ids())
.arg(yes()),
))
.subcommand(with_common(
CommandSpec::new("remove")
.about("Uninstall packages: the lockfile entry, then the file")
.usage("remove ID...")
.arg(ids()),
))
.subcommand(with_common(
CommandSpec::new("list")
.about("What is installed: version, kind, tier, enabled, whether the file still matches the lockfile, and yank reasons last seen; no network")
.usage("list"),
))
.subcommand(with_common(
CommandSpec::new("enable")
.about("Load an installed package again on every run")
.usage("enable ID...")
.arg(ids()),
))
.subcommand(with_common(
CommandSpec::new("disable")
.about("Keep a package installed but do not load it")
.usage("disable ID...")
.arg(ids()),
))
.subcommand(with_common(
CommandSpec::new("verify")
.about(
"Check every installed file against the lockfile, and every installed \
version against the current index; yanked versions are reported with their \
reason; exits 4 when a file or listing does not match",
)
.usage("verify"),
))
.subcommand(with_common(
CommandSpec::new("browse")
.about("The marketplace in a selector, with each package's provenance (tier, source, hash, permissions) beside the list; Enter installs, updates, removes, enables or disables")
.usage("browse"),
))
.subcommand(
CommandSpec::new("publish")
.about(
"Check a local package (a WASM plugin, a micro pack or a theme), compute its \
SHA-256 and size, and write its listing JSON for a pull request to the \
marketplace repository; no network",
)
.usage(
"publish FILE --url URL [--listing FILE | --license SPDX --repository URL \
[--description TEXT]] [--source-revision COMMIT] [--output FILE]",
)
.arg(ArgSpec::positional("file").help("The .wasm, .zip/.richmicro or .ini/.theme file"))
.arg(publish_option("url", "URL", "Where the file will be downloaded from (https://, ending in its extension)"))
.arg(publish_option("listing", "FILE", "An existing listing to add this version to (it is rewritten unless --output is given)"))
.arg(publish_option("output", "FILE", "Where to write the listing (default packages/ID.json)"))
.arg(publish_option("source-revision", "COMMIT", "The full Git commit the artifact was built from (required for a WASM plugin)"))
.arg(publish_option("id", "ID", "A theme's id (default: the file name); for a plugin or pack, checked against its manifest"))
.arg(publish_option("version", "VERSION", "A theme's version; for a plugin or pack, checked against its manifest"))
.arg(publish_option("name", "TEXT", "The human-readable name (default: the id)"))
.arg(publish_option("description", "TEXT", "One line on what it adds (default: a WASM plugin's or micro pack's own)"))
.arg(publish_option("tier", "TIER", "community (the default), reviewed or official; raising it needs a maintainer"))
.arg(publish_option("license", "SPDX", "The SPDX license expression (default: a micro pack's pack.json license)"))
.arg(publish_option("repository", "URL", "The source repository, https://"))
.arg(publish_option("homepage", "URL", "A homepage, https://"))
.arg(publish_option("author", "NAME", "An author; repeatable"))
.arg(publish_option("keyword", "WORD", "A search keyword; repeatable, at most 8"))
.arg(publish_option("published", "DATE", "The listing date, YYYY-MM-DD (default: today, UTC)"))
.example(
"rich plugin publish shout.wasm --url https://github.com/me/shout/releases/download/v0.2.0/shout.wasm --source-revision $(git rev-parse HEAD) --license MIT --repository https://github.com/me/shout",
"A new listing, packages/shout.json",
)
.example(
"rich plugin publish shout.wasm --url URL --source-revision COMMIT --listing packages/shout.json",
"Add a version to an existing listing",
),
)
.section(
"On the web",
format!(
"The marketplace's own site, {MARKETPLACE_SITE}, searches and filters the same \
signed index in a browser; so does the docs site's Marketplace page, \
{MARKETPLACE_PAGE}. Both only show it: `rich plugin install` checks the \
signature."
),
)
.section(
"Environment",
"- RICH_MARKETPLACE: the marketplace, as --marketplace\n\
- RICH_MARKETPLACE_KEYS: a keys-N.json to trust, as --marketplace-keys\n\
- RICH_PLUGIN_DIR: the install directory, as --plugin-dir; also where every run \
looks for installed plugins",
)
}
#[derive(Debug)]
pub(crate) struct Failure {
pub class: ExitClass,
pub message: String,
}
pub(crate) type Outcome<T> = Result<T, Failure>;
pub(crate) fn usage(message: impl Into<String>) -> Failure {
Failure {
class: ExitClass::Usage,
message: message.into(),
}
}
pub(crate) fn input(message: impl Into<String>) -> Failure {
Failure {
class: ExitClass::Input,
message: message.into(),
}
}
pub(crate) fn data(message: impl Into<String>) -> Failure {
Failure {
class: ExitClass::Data,
message: message.into(),
}
}
fn index_failure(error: IndexError) -> Failure {
data(format!("marketplace index refused: {error}"))
}
fn keys_failure(error: KeyChainError) -> Failure {
match error {
KeyChainError::NoTrustRoot => usage(
"this build has no marketplace keys compiled in yet; pass --marketplace-keys FILE \
(or set RICH_MARKETPLACE_KEYS) to a keys-N.json you trust",
),
other => data(format!("marketplace keys refused: {other}")),
}
}
fn listing_failure(error: ListingError) -> Failure {
match error {
ListingError::NotInIndex(id) => usage(format!(
"no package {:?} in the marketplace",
controls::shown(&id)
)),
other => data(other.to_string()),
}
}
fn resolve_failure(error: ResolveError) -> Failure {
match error {
ResolveError::NoSuchVersion { .. } => usage(error.to_string()),
other => data(other.to_string()),
}
}
fn fetch_failure(error: FetchError) -> Failure {
match error {
FetchError::TooLarge { .. } | FetchError::NotHttps { .. } => data(error.to_string()),
other => input(other.to_string()),
}
}
fn lockfile_failure(error: LockfileError) -> Failure {
match error {
LockfileError::Io { .. } => input(error.to_string()),
LockfileError::NotInstalled(_) => usage(error.to_string()),
other => data(other.to_string()),
}
}
fn install_failure(error: InstallError) -> Failure {
match error {
InstallError::NotInstallable { .. } => usage(error.to_string()),
InstallError::Index(e) => index_failure(e),
InstallError::Listing(e) => listing_failure(e),
InstallError::Resolve(e) => resolve_failure(e),
InstallError::Fetch(e) => fetch_failure(e),
InstallError::Lockfile(e) => lockfile_failure(e),
other => data(other.to_string()),
}
}
#[derive(Debug, Default)]
pub(crate) struct Options {
pub command: String,
pub positionals: Vec<String>,
pub json: bool,
pub no_color: bool,
pub yes: bool,
pub marketplace: Option<String>,
pub plugin_dir: Option<PathBuf>,
pub keys: Option<PathBuf>,
pub sort: Option<String>,
pub kind: Option<Kind>,
pub tier: Option<Tier>,
pub recommended: bool,
pub publish: BTreeMap<&'static str, Vec<String>>,
}
const PUBLISH_OPTIONS: &[&str] = &[
"--url",
"--listing",
"--output",
"--id",
"--name",
"--description",
"--tier",
"--license",
"--repository",
"--homepage",
"--source-revision",
"--version",
"--published",
"--author",
"--keyword",
];
fn parse(args: &[String]) -> Outcome<Options> {
let mut options = Options::default();
let mut words = Vec::new();
let mut iter = args.iter();
let mut no_color = None;
let mut kind = None;
while let Some(arg) = iter.next() {
let mut value = |name: &str| {
iter.next()
.cloned()
.ok_or_else(|| usage(format!("{name} requires a value")))
};
match arg.as_str() {
"--marketplace" => options.marketplace = Some(value(arg)?),
"--plugin-dir" => options.plugin_dir = Some(fs_path(&value(arg)?)),
"--marketplace-keys" => options.keys = Some(fs_path(&value(arg)?)),
"--sort" => {
let order = value(arg)?;
if !matches!(order.as_str(), "id" | "downloads") {
return Err(usage(format!(
"unknown --sort {:?} (id, downloads)",
controls::shown(&order)
)));
}
options.sort = Some(order);
}
"--kind" => kind = Some(value(arg)?),
"--recommended" => options.recommended = true,
"--report" => {
options.json = match value(arg)?.as_str() {
"json" => true,
"human" => false,
other => {
return Err(usage(format!(
"unknown report format {other:?} (human, json)"
)));
}
}
}
"--machine-json" => options.json = true,
"--yes" | "-y" => options.yes = true,
"--no-color" => no_color = Some(true),
"--color" => no_color = Some(false),
"--" => words.extend(iter.by_ref().cloned()),
other if other.starts_with("--") => {
match PUBLISH_OPTIONS.iter().find(|known| **known == other) {
Some(known) => {
let v = value(other)?;
options.publish.entry(&known[2..]).or_default().push(v);
}
None => {
return Err(usage(format!(
"unexpected argument {:?}; see `rich plugin --help`",
controls::shown(other)
)));
}
}
}
other if other.starts_with('-') && other != "-" => {
return Err(usage(format!(
"unexpected argument {:?}; see `rich plugin --help`",
controls::shown(other)
)));
}
_ => words.push(arg.clone()),
}
}
let mut words = words.into_iter().skip(1);
options.command = words.next().unwrap_or_default();
options.positionals = words.collect();
if options.command.is_empty() {
return Err(usage(format!(
"rich plugin needs a command: {}",
COMMANDS.join(", ")
)));
}
if !COMMANDS.contains(&options.command.as_str()) {
return Err(usage(format!(
"unknown command {:?}; commands: {} (`rich plugins` lists what this run has loaded)",
controls::shown(&options.command),
COMMANDS.join(", ")
)));
}
if options.sort.is_some() && options.command != "search" {
return Err(usage("--sort only applies to `rich plugin search`"));
}
if let Some(name) = kind {
if options.command != "search" {
return Err(usage("--kind only applies to `rich plugin search`"));
}
options.kind = Some(Kind::parse(&name).ok_or_else(|| {
usage(format!(
"unknown kind {:?} (crate, wasm-plugin, micro-pack, theme)",
controls::shown(&name)
))
})?);
}
if options.recommended && options.command != "search" {
return Err(usage("--recommended only applies to `rich plugin search`"));
}
if options.command == "search"
&& let Some(name) = options.publish.remove("tier").and_then(|mut v| v.pop())
{
options.tier = Some(Tier::parse(&name).ok_or_else(|| {
usage(format!(
"unknown tier {:?} (official, reviewed, community)",
controls::shown(&name)
))
})?);
}
if options.command != "publish" && !options.publish.is_empty() {
let names: Vec<String> = options.publish.keys().map(|k| format!("--{k}")).collect();
return Err(usage(format!(
"{} only apply to `rich plugin publish`",
names.join(", ")
)));
}
let env_no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
options.no_color = no_color.unwrap_or(env_no_color);
Ok(options)
}
pub(super) fn dispatch(args: &[String]) -> ExitCode {
let json = wants_json_report(args);
if args
.iter()
.take_while(|arg| *arg != "--")
.any(|arg| arg == "--help" || arg == "-h")
{
let no_color = cli_spec::no_color_requested(args);
let sub = args
.iter()
.find(|arg| COMMANDS.contains(&arg.as_str()))
.map(String::as_str);
let mut path = vec!["plugin"];
path.extend(sub);
if let Some(help) = cli_spec::subcommand_help(&path, no_color) {
authoring::out(&format!("{help}\n"));
}
return ExitCode::SUCCESS;
}
let options = match parse(args) {
Ok(options) => options,
Err(failure) => return emit_error(json, failure.class, &failure.message),
};
let result = match options.command.as_str() {
"search" => search(&options),
"info" => info(&options),
"install" => install(&options),
"update" => update(&options),
"remove" => remove(&options),
"list" => list(&options),
"enable" => set_enabled(&options, true),
"disable" => set_enabled(&options, false),
"verify" => verify(&options),
"publish" => publish::run(&options),
#[cfg(feature = "interact")]
"browse" => browse::run(&options),
#[cfg(not(feature = "interact"))]
"browse" => Err(usage(
"`rich plugin browse` needs a build with the interact feature; use `rich plugin \
search` and `rich plugin info`",
)),
_ => unreachable!("checked by parse"),
};
finish(&options, result)
}
pub(crate) struct Done {
pub text: String,
pub result: Value,
pub problem: Option<Failure>,
pub declined: bool,
}
impl Done {
pub(crate) fn new(text: String, result: Value) -> Self {
Done {
text,
result,
problem: None,
declined: false,
}
}
}
fn finish(options: &Options, result: Outcome<Done>) -> ExitCode {
match result {
Err(failure) => emit_error(options.json, failure.class, &failure.message),
Ok(done) => {
if !options.json {
authoring::out(&done.text);
}
if done.declined {
let message = "cancelled; nothing was installed";
if options.json {
eprintln!(
"{}",
json!({
"ok": false, "code": "cancelled", "exit_code": EXIT_DECLINED,
"message": message, "error": {"message": message},
"result": done.result,
})
);
} else {
eprintln!("rich: {message}");
}
return ExitCode::from(EXIT_DECLINED);
}
match done.problem {
Some(failure) => {
let message = controls::shown(&failure.message);
if options.json {
eprintln!(
"{}",
json!({
"ok": false, "code": failure.class.name(),
"exit_code": failure.class.code(), "message": message,
"error": {"message": message}, "result": done.result,
})
);
} else {
eprintln!("rich: {message}");
}
failure.class.exit_code()
}
None => {
if options.json {
eprintln!(
"{}",
json!({
"ok": true, "code": ExitClass::Success.name(),
"exit_code": ExitClass::Success.code(), "result": done.result,
})
);
}
ExitCode::SUCCESS
}
}
}
}
}
pub(crate) fn plugin_dir(explicit: Option<&Path>) -> Option<PathBuf> {
if let Some(dir) = explicit {
return Some(dir.to_path_buf());
}
let env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty());
if let Some(dir) = env("RICH_PLUGIN_DIR") {
return Some(PathBuf::from(dir));
}
if cfg!(windows)
&& let Some(local) = env("LOCALAPPDATA")
{
return Some(PathBuf::from(local).join("rich").join("plugins"));
}
if let Some(data) = env("XDG_DATA_HOME").map(PathBuf::from)
&& data.is_absolute()
{
return Some(data.join("rich").join("plugins"));
}
env("HOME")
.or_else(|| env("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".local/share/rich/plugins"))
}
fn require_dir(options: &Options) -> Outcome<PathBuf> {
plugin_dir(options.plugin_dir.as_deref()).ok_or_else(|| {
usage("no install directory: set HOME, RICH_PLUGIN_DIR or pass --plugin-dir DIR")
})
}
#[derive(Clone, Debug, Default, PartialEq)]
struct MarketState {
accepted: Option<AcceptedIndex>,
}
#[derive(Clone, Debug, PartialEq)]
struct CachedRotation {
keys_json: String,
main_signature: Option<String>,
backup_signature: String,
}
impl CachedRotation {
fn rotation(&self) -> Rotation<'_> {
Rotation {
keys_json: self.keys_json.as_bytes(),
main_signature: self.main_signature.as_deref(),
backup_signature: &self.backup_signature,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct State {
disabled: Vec<String>,
markets: BTreeMap<String, MarketState>,
chains: BTreeMap<String, Vec<CachedRotation>>,
yanked: BTreeMap<String, (String, String)>,
}
impl State {
fn path(dir: &Path) -> PathBuf {
dir.join(STATE_NAME)
}
pub(crate) fn load(dir: &Path) -> Outcome<State> {
let path = State::path(dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(State::default()),
Err(e) => return Err(input(format!("{}: {e}", path.display()))),
};
let bad = |why: &str| data(format!("{}: {why}", path.display()));
let value: Value = serde_json::from_slice(&bytes).map_err(|e| bad(&e.to_string()))?;
if value.get("schema_version").and_then(Value::as_u64) != Some(STATE_SCHEMA_VERSION) {
return Err(bad("unsupported or missing schema_version"));
}
let strings = |v: Option<&Value>| -> Vec<String> {
v.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
};
let mut state = State {
disabled: strings(value.get("disabled")),
..State::default()
};
if let Some(markets) = value.get("marketplaces").and_then(Value::as_object) {
for (location, market) in markets {
let accepted = match (
market.get("serial").and_then(Value::as_u64),
market.get("sha256").and_then(Value::as_str),
) {
(Some(serial), Some(sha256)) => Some(AcceptedIndex {
serial,
sha256: sha256.to_string(),
}),
_ => None,
};
state
.markets
.insert(location.clone(), MarketState { accepted });
}
}
if let Some(chains) = value.get("key_chains").and_then(Value::as_object) {
for (root, chain) in chains {
let Some(chain) = chain.as_array() else {
continue;
};
let chain: Option<Vec<CachedRotation>> = chain
.iter()
.map(|entry| {
Some(CachedRotation {
keys_json: entry.get("keys")?.as_str()?.to_string(),
main_signature: match entry.get("main_signature") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str()?.to_string()),
},
backup_signature: entry.get("backup_signature")?.as_str()?.to_string(),
})
})
.collect();
if let Some(chain) = chain {
state.chains.insert(root.clone(), chain);
}
}
}
if let Some(yanked) = value.get("yanked").and_then(Value::as_object) {
for (id, entry) in yanked {
if let (Some(version), Some(reason)) = (
entry.get("version").and_then(Value::as_str),
entry.get("reason").and_then(Value::as_str),
) {
state
.yanked
.insert(id.clone(), (version.to_string(), reason.to_string()));
}
}
}
Ok(state)
}
fn to_json(&self) -> String {
let mut markets = serde_json::Map::new();
for (location, market) in &self.markets {
let mut entry = serde_json::Map::new();
if let Some(accepted) = &market.accepted {
entry.insert("serial".into(), json!(accepted.serial));
entry.insert("sha256".into(), json!(accepted.sha256));
}
markets.insert(location.clone(), Value::Object(entry));
}
let chains: serde_json::Map<String, Value> = self
.chains
.iter()
.map(|(root, chain)| {
let entries = chain
.iter()
.map(|c| {
json!({
"keys": c.keys_json,
"main_signature": c.main_signature,
"backup_signature": c.backup_signature,
})
})
.collect();
(root.clone(), Value::Array(entries))
})
.collect();
let yanked: serde_json::Map<String, Value> = self
.yanked
.iter()
.map(|(id, (version, reason))| {
(id.clone(), json!({"version": version, "reason": reason}))
})
.collect();
let value = json!({
"schema_version": STATE_SCHEMA_VERSION,
"disabled": self.disabled,
"marketplaces": markets,
"key_chains": chains,
"yanked": yanked,
});
serde_json::to_string_pretty(&value).unwrap_or_default() + "\n"
}
pub(crate) fn save(&self, dir: &Path) -> Outcome<()> {
write_atomically(&State::path(dir), self.to_json().as_bytes())
.map_err(|e| input(format!("{}: {e}", State::path(dir).display())))
}
pub(crate) fn is_enabled(&self, id: &str) -> bool {
!self.disabled.iter().any(|d| d == id)
}
fn set_enabled(&mut self, id: &str, enabled: bool) {
self.disabled.retain(|d| d != id);
if !enabled {
self.disabled.push(id.to_string());
self.disabled.sort();
}
}
fn note_yank(&mut self, id: &str, version: &str, listed: Option<&ListedVersion>) {
match listed.and_then(|v| v.yanked.as_ref()) {
Some(reason) => {
self.yanked
.insert(id.to_string(), (version.to_string(), reason.clone()));
}
None => {
self.yanked.remove(id);
}
}
}
fn yank_reason(&self, entry: &LockEntry) -> Option<&str> {
self.yanked
.get(&entry.id)
.filter(|(version, _)| *version == entry.version)
.map(|(_, reason)| reason.as_str())
}
}
fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
rich_marketplace::create_private_dir_all(dir)?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let temporary = dir.join(format!(".{name}.{}.partial", std::process::id()));
let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, path));
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Location {
Local(PathBuf),
Remote(String),
}
impl Location {
fn parse(text: &str) -> Outcome<Location> {
if let Some(rest) = text.strip_prefix("file://") {
let path = rest.strip_prefix("localhost").unwrap_or(rest);
if path.is_empty() {
return Err(usage("--marketplace file:// needs a path"));
}
return Ok(Location::Local(PathBuf::from(percent_decode(path))));
}
if text.starts_with("https://") {
let mut base = text.to_string();
if !base.ends_with('/') {
base.push('/');
}
return Ok(Location::Remote(base));
}
if text.contains("://") {
return Err(usage(format!(
"marketplace {:?}: only https:// URLs, file:// URLs and directories are allowed",
controls::shown(text)
)));
}
Ok(Location::Local(fs_path(text)))
}
fn key(&self) -> String {
match self {
Location::Local(path) => {
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
format!("file://{}", path.display())
}
Location::Remote(url) => url.clone(),
}
}
fn shown(&self) -> String {
match self {
Location::Local(path) => path.display().to_string(),
Location::Remote(url) => url.clone(),
}
}
fn fetch(&self, relative: &str, max: u64) -> Outcome<Option<Vec<u8>>> {
match self {
Location::Local(root) => {
let path = relative
.split('/')
.fold(root.clone(), |path, part| path.join(part));
read_bounded(&path, max).map_err(fetch_failure)
}
Location::Remote(base) => {
#[cfg(feature = "fetch")]
{
let url = format!("{base}{relative}");
match rich_marketplace::HttpsTransport::new().get(&url, max) {
Ok(bytes) => Ok(Some(bytes)),
Err(FetchError::Status { status: 404, .. }) => Ok(None),
Err(error) => Err(fetch_failure(error)),
}
}
#[cfg(not(feature = "fetch"))]
{
let _ = (base, relative, max);
Err(usage(
"this build has no network access (the fetch feature); pass \
--marketplace DIR for a local marketplace",
))
}
}
}
}
fn transport(&self) -> Outcome<ArtifactTransport> {
match self {
Location::Local(root) => Ok(ArtifactTransport::Mirror(root.join("artifacts"))),
#[cfg(feature = "fetch")]
Location::Remote(_) => Ok(ArtifactTransport::Https(
rich_marketplace::HttpsTransport::new(),
)),
#[cfg(not(feature = "fetch"))]
Location::Remote(_) => Err(usage(
"this build has no network access (the fetch feature); pass --marketplace DIR \
for a local marketplace",
)),
}
}
}
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let hex = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(high), Some(low)) = (hex(bytes[i + 1]), hex(bytes[i + 2]))
{
out.push(high << 4 | low);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn read_bounded(path: &Path, max: u64) -> Result<Option<Vec<u8>>, FetchError> {
use std::io::Read;
let url = path.display().to_string();
let file = match std::fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(FetchError::Transport {
url,
message: e.to_string(),
});
}
};
let mut bytes = Vec::new();
file.take(max.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|e| FetchError::Transport {
url: url.clone(),
message: e.to_string(),
})?;
if bytes.len() as u64 > max {
return Err(FetchError::TooLarge { url, limit: max });
}
Ok(Some(bytes))
}
pub(crate) enum ArtifactTransport {
Mirror(PathBuf),
#[cfg(feature = "fetch")]
Https(rich_marketplace::HttpsTransport),
}
impl Transport for ArtifactTransport {
fn get(&self, url: &str, max_bytes: u64) -> Result<Vec<u8>, FetchError> {
rich_marketplace::require_https(url)?;
match self {
ArtifactTransport::Mirror(root) => {
let not_found = || FetchError::NotFound {
url: url.to_string(),
};
let rest = &url["https://".len()..];
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let mut path = root.clone();
for part in rest.split('/') {
if part.is_empty()
|| part == "."
|| part == ".."
|| part.contains(['\\', ':', '\0'])
{
return Err(not_found());
}
path.push(part);
}
read_bounded(&path, max_bytes)
.map_err(|error| match error {
FetchError::TooLarge { limit, .. } => FetchError::TooLarge {
url: url.to_string(),
limit,
},
other => other,
})?
.ok_or_else(not_found)
}
#[cfg(feature = "fetch")]
ArtifactTransport::Https(transport) => transport.get(url, max_bytes),
}
}
}
fn location(options: &Options) -> Outcome<Location> {
let text = options
.marketplace
.clone()
.or_else(|| {
std::env::var("RICH_MARKETPLACE")
.ok()
.filter(|v| !v.is_empty())
})
.unwrap_or_else(|| DEFAULT_MARKETPLACE.to_string());
Location::parse(&text)
}
fn trust_root(options: &Options) -> Outcome<KeySet> {
let file = options.keys.clone().or_else(|| {
std::env::var_os("RICH_MARKETPLACE_KEYS")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
});
match file {
Some(path) => {
let bytes = std::fs::read(&path)
.map_err(|e| input(format!("marketplace keys {}: {e}", path.display())))?;
KeySet::from_json(&bytes)
.map_err(|e| data(format!("marketplace keys {}: {e}", path.display())))
}
None => KeySet::official().map_err(keys_failure),
}
}
pub(crate) struct Market {
pub location: Location,
pub index: VerifiedIndex,
}
pub(crate) fn open(options: &Options, dir: &Path, state: &mut State) -> Outcome<Market> {
let location = location(options)?;
let key = location.key();
let root = trust_root(options)?;
let root_id = root.main().id().to_string();
let market = state.markets.get(&key).cloned().unwrap_or_default();
let (mut keys, mut chain) = match replay(&root, state.chains.get(&root_id)) {
Ok(replayed) => replayed,
Err(why) => {
eprintln!(
"rich: warning: the key chain cached in {} does not verify from the trust \
root ({why}); it is ignored and fetched again",
State::path(dir).display()
);
state.chains.remove(&root_id);
let _ = state.save(dir);
(root.clone(), Vec::new())
}
};
let mut notices = Vec::new();
for _ in 0..MAX_ROTATIONS {
let next = keys.generation() + 1;
let name = format!("keys/keys-{next}.json");
let Some(keys_json) = location.fetch(&name, MAX_KEYS_BYTES)? else {
break;
};
let backup = location
.fetch(&format!("{name}.backup.minisig"), MAX_SIGNATURE_BYTES)?
.ok_or_else(|| data(format!("{name} has no backup signature")))?;
let main = location.fetch(&format!("{name}.main.minisig"), MAX_SIGNATURE_BYTES)?;
let backup = String::from_utf8_lossy(&backup).into_owned();
let main = main.map(|m| String::from_utf8_lossy(&m).into_owned());
let (set, kind) = keys
.rotate(Rotation {
keys_json: &keys_json,
main_signature: main.as_deref(),
backup_signature: &backup,
})
.map_err(keys_failure)?;
if let RotationKind::MainKeyReplaced { revoked } = kind {
notices.push(format!(
"the marketplace's main key {revoked} was revoked and replaced (keys generation \
{next})"
));
}
keys = set;
if let Ok(keys_json) = String::from_utf8(keys_json) {
chain.push(CachedRotation {
keys_json,
main_signature: main,
backup_signature: backup,
});
}
}
let index = location
.fetch("index.json", MAX_INDEX_BYTES)?
.ok_or_else(|| {
input(format!(
"no index.json in the marketplace at {}",
location.shown()
))
})?;
let signature = location
.fetch("index.json.minisig", MAX_SIGNATURE_BYTES)?
.ok_or_else(|| {
data(format!(
"the marketplace at {} has no index.json.minisig",
location.shown()
))
})?;
let signature = String::from_utf8_lossy(&signature);
let verified = verify_index(
&keys,
&index,
&signature,
market.accepted.as_ref(),
SystemTime::now(),
)
.map_err(|error| stale_keys_hint(error, &keys, &location))?;
state.markets.insert(
key,
MarketState {
accepted: Some(verified.accepted()),
},
);
if chain.is_empty() {
state.chains.remove(&root_id);
} else {
state.chains.insert(root_id, chain);
}
if let Err(failure) = state.save(dir) {
eprintln!("rich: warning: {}", failure.message);
}
for notice in ¬ices {
eprintln!("rich: warning: {notice}");
}
Ok(Market {
location,
index: verified,
})
}
fn replay(
root: &KeySet,
cached: Option<&Vec<CachedRotation>>,
) -> Result<(KeySet, Vec<CachedRotation>), KeyChainError> {
let mut keys = root.clone();
let mut kept = Vec::new();
for rotation in cached.into_iter().flatten() {
let generation = KeySet::from_json(rotation.keys_json.as_bytes())?.generation();
if generation <= keys.generation() {
continue;
}
keys = keys.rotate(rotation.rotation())?.0;
kept.push(rotation.clone());
}
Ok((keys, kept))
}
fn stale_keys_hint(error: IndexError, keys: &KeySet, location: &Location) -> Failure {
let found = match &error {
IndexError::Signature(rich_marketplace::SignatureError::WrongKeyId { found, .. }) => *found,
_ => return index_failure(error),
};
let mut failure = index_failure(error);
let generation = keys.generation();
if keys.revoked().contains(&found) {
failure.message.push_str(&format!(
"; key {found} was revoked by keys generation {generation} or earlier, so the \
marketplace at {} is stale or is withholding newer keys",
location.shown()
));
} else {
failure.message.push_str(&format!(
"; if the marketplace rotated its keys, {} may be missing keys/keys-{}.json",
location.shown(),
generation + 1
));
}
failure
}
impl Market {
pub(crate) fn listing(&self, id: &str) -> Outcome<Listing> {
let entry = self.index.entry(id).ok_or_else(|| {
usage(format!(
"no package {:?} in the marketplace at {}",
controls::shown(id),
self.location.shown()
))
})?;
let bytes = self
.location
.fetch(&entry.path, MAX_LISTING_BYTES)?
.ok_or_else(|| data(format!("{} is missing from the marketplace", entry.path)))?;
self.index.read_listing(id, &bytes).map_err(listing_failure)
}
pub(crate) fn listings(&self) -> Outcome<Vec<Listing>> {
self.index
.listings()
.keys()
.map(|id| self.listing(id))
.collect()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct SandboxChecker;
impl ArtifactChecker for SandboxChecker {
fn check_wasm(&self, bytes: &[u8], expected: &WasmExpectation<'_>) -> Result<(), CheckError> {
rich_marketplace::check_wasm_header(bytes)?;
#[cfg(feature = "wasm-plugins")]
{
let label = PathBuf::from(format!("{}-{}.wasm", expected.id, expected.version));
let plugin = rich_ext::plugin_loading::load_wasm_bytes(
bytes,
&label,
&rich_ext::plugin_loading::WasmLimits::default(),
)
.map_err(|e| CheckError::Host(format!("the module does not load: {e}")))?;
rich_marketplace::compare_wasm_manifest(plugin.abi(), expected)
}
#[cfg(not(feature = "wasm-plugins"))]
{
let _ = expected;
Err(CheckError::Host(
"this build cannot load WASM plugins; it needs the wasm-plugins feature".into(),
))
}
}
}
pub(crate) fn has_installed() -> bool {
plugin_dir(None).is_some_and(|dir| dir.join(LOCKFILE_NAME).is_file())
}
fn installed_entries(kind: Kind, what: &str) -> (Option<(PathBuf, Vec<LockEntry>)>, Vec<String>) {
let mut warnings = Vec::new();
let Some(dir) = plugin_dir(None) else {
return (None, warnings);
};
let lock_path = dir.join(LOCKFILE_NAME);
if !lock_path.is_file() {
return (None, warnings);
}
let lockfile = match Lockfile::load(&lock_path) {
Ok(lockfile) => lockfile,
Err(error) => {
warnings.push(format!("installed {what} are not loaded: {error}"));
return (None, warnings);
}
};
let state = match State::load(&dir) {
Ok(state) => state,
Err(failure) => {
warnings.push(format!(
"installed {what} are not loaded: {}",
failure.message
));
return (None, warnings);
}
};
let wanted: Vec<LockEntry> = lockfile
.entries()
.filter(|e| e.kind == kind && state.is_enabled(&e.id))
.cloned()
.collect();
(Some((dir, wanted)), warnings)
}
pub(crate) fn installed_plugins(skip: &[String]) -> (Vec<RuntimePlugin>, Vec<String>) {
let mut plugins = Vec::new();
let (found, mut warnings) = installed_entries(Kind::WasmPlugin, "plugins");
let Some((dir, wanted)) = found else {
return (plugins, warnings);
};
let wanted: Vec<&LockEntry> = wanted.iter().filter(|e| !skip.contains(&e.id)).collect();
if wanted.is_empty() {
return (plugins, warnings);
}
if !RuntimeKind::Wasm.supported() {
let ids: Vec<&str> = wanted.iter().map(|e| e.id.as_str()).collect();
warnings.push(format!(
"installed WASM plugins ({}) are not loaded: this build lacks the wasm-plugins \
feature",
ids.join(", ")
));
return (plugins, warnings);
}
for entry in wanted {
match load_entry(&dir, entry) {
Ok(plugin) => plugins.push(plugin),
Err(message) => warnings.push(format!(
"plugin {} is not loaded: {message}; `rich plugin verify` checks every installed \
file",
entry.id
)),
}
}
(plugins, warnings)
}
#[cfg_attr(not(feature = "art"), allow(dead_code))]
pub(crate) fn installed_micro_packs() -> (Vec<(String, PathBuf)>, Vec<String>) {
let mut packs = Vec::new();
let (found, mut warnings) = installed_entries(Kind::MicroPack, "micro packs");
let Some((dir, wanted)) = found else {
return (packs, warnings);
};
for entry in wanted {
match entry.verify(&dir) {
Ok(_) => packs.push((entry.id.clone(), entry.file(&dir))),
Err(error) => warnings.push(format!(
"micro pack {} is not loaded: {error}; `rich plugin verify` checks every \
installed file",
entry.id
)),
}
}
(packs, warnings)
}
pub(crate) fn installed_theme(id: &str) -> (Option<BTreeMap<String, String>>, Vec<String>) {
let (found, mut warnings) = installed_entries(Kind::Theme, "themes");
let Some((dir, wanted)) = found else {
return (None, warnings);
};
let Some(entry) = wanted.iter().find(|e| e.id == id) else {
return (None, warnings);
};
let styles = entry
.verify(&dir)
.map_err(|error| error.to_string())
.and_then(|bytes| theme_bindings(&bytes));
match styles {
Ok(styles) => (Some(styles), warnings),
Err(error) => {
warnings.push(format!(
"theme {id} is not loaded: {error}; `rich plugin verify` checks every installed \
file"
));
(None, warnings)
}
}
}
fn theme_bindings(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
let text = std::str::from_utf8(bytes).map_err(|_| "not UTF-8".to_string())?;
let theme = rich::Theme::from_file(text, false).map_err(|error| error.to_string())?;
let mut styles = BTreeMap::new();
for name in theme.names() {
let Some(style) = theme.get(name) else {
continue;
};
let definition = style.definition();
super::config::validate_theme_binding(name, &definition)?;
styles.insert(name.to_owned(), definition);
}
Ok(styles)
}
#[cfg(feature = "wasm-plugins")]
fn load_entry(dir: &Path, entry: &LockEntry) -> Result<RuntimePlugin, String> {
let bytes = entry.verify(dir).map_err(|e| e.to_string())?;
let plugin = rich_ext::plugin_loading::load_wasm_bytes(
&bytes,
&entry.file(dir),
&rich_ext::plugin_loading::WasmLimits::default(),
)
.map_err(|e| e.to_string())?;
if plugin.abi().name != entry.id {
return Err(format!(
"its manifest names {:?}, but it is installed as {}",
plugin.abi().name,
entry.id
));
}
Ok(plugin)
}
#[cfg(not(feature = "wasm-plugins"))]
fn load_entry(_: &Path, _: &LockEntry) -> Result<RuntimePlugin, String> {
Err("this build lacks the wasm-plugins feature".into())
}
pub(crate) fn line(text: &str) -> String {
rich_ext::sanitize::sanitize_single_line(text)
}
fn escape(text: &str) -> String {
rich::markup::escape(&line(text))
}
pub(crate) fn tier_meaning(tier: Tier) -> &'static str {
match tier {
Tier::Official => "rs-rich's own, built from its repository",
Tier::Reviewed => "a maintainer reviewed the source and rebuilt the same artifact",
_ => "checked by the marketplace's CI only, not reviewed",
}
}
pub(crate) fn tier_label(tier: Tier) -> &'static str {
match tier {
Tier::Official => "✓ official",
Tier::Reviewed => "approved",
_ => "community",
}
}
pub(crate) fn tier_display(tier: Tier) -> &'static str {
match tier {
Tier::Official => "Official",
Tier::Reviewed => "Approved",
_ => "Community",
}
}
pub(crate) const RECOMMENDED_LABEL: &str = "★ recommended";
pub(crate) fn marks(listing: &Listing) -> String {
let mut text = tier_marker(listing.tier);
if listing.recommended.is_some() {
text.push_str(&format!("\n[bold magenta]{RECOMMENDED_LABEL}[/]"));
}
text
}
pub(crate) fn recommended_text(recommended: &rich_marketplace::Recommended) -> String {
format!(
"{RECOMMENDED_LABEL} since {}: {}",
recommended.since, recommended.note
)
}
pub(crate) fn tier_marker(tier: Tier) -> String {
match tier {
Tier::Official => format!("[bold green]{}[/]", tier_label(tier)),
Tier::Reviewed => tier_label(tier).to_string(),
_ => format!("[yellow]{}[/]", tier_label(tier)),
}
}
pub(crate) fn repository_source(repository: &str) -> String {
let rest = repository
.strip_prefix("https://")
.unwrap_or(repository)
.trim_end_matches('/');
line(rest.strip_suffix(".git").unwrap_or(rest))
}
pub(crate) fn repository_owner(repository: &str) -> String {
let source = repository_source(repository);
let mut parts = source.split('/');
let host = parts.next().unwrap_or_default().to_string();
parts
.next()
.filter(|owner| !owner.is_empty())
.map_or(host, str::to_string)
}
const MAX_AUTHOR_CHARS: usize = 40;
const MAX_AUTHORS: usize = 3;
pub(crate) fn authored_by(listing: &Listing) -> String {
format!("Authored by {}", authors_text(listing))
}
pub(crate) fn authors_text(listing: &Listing) -> String {
if listing.tier == Tier::Official {
return "rs-rich".into();
}
let mut names: Vec<String> = listing
.authors
.iter()
.take(MAX_AUTHORS)
.map(|author| {
let author = line(author);
let mut short: String = author.chars().take(MAX_AUTHOR_CHARS).collect();
if short.len() < author.len() {
short.push('…');
}
if short.to_lowercase().contains("rs-rich") {
format!("\"{short}\"")
} else {
short
}
})
.collect();
if listing.authors.len() > MAX_AUTHORS {
names.push(format!("and {} more", listing.authors.len() - MAX_AUTHORS));
}
if names.is_empty() {
names.push(repository_owner(&listing.repository));
}
format!(
"{} from {}",
names.join(", "),
repository_source(&listing.repository)
)
}
pub(crate) fn publisher(listing: &Listing) -> String {
let owner = repository_owner(&listing.repository);
match listing.tier {
Tier::Official => "Official: built and published by rs-rich".into(),
Tier::Reviewed => format!(
"Approved: published by {owner}, not by rs-rich; a maintainer read its source and \
rebuilt it"
),
_ => format!(
"Community: published by {owner}, not by rs-rich. Made by its author: check its \
source and permissions"
),
}
}
pub(crate) fn row_markup(key: &str, value: &str) -> String {
if key == "Recommended"
&& let Some(rest) = value.strip_prefix(RECOMMENDED_LABEL)
{
return format!("[bold magenta]{RECOMMENDED_LABEL}[/]{}", escape(rest));
}
if key == "Tier" {
for tier in [Tier::Official, Tier::Reviewed, Tier::Community] {
if let Some(rest) = value.strip_prefix(tier_label(tier)) {
return format!("{}{}", tier_marker(tier), escape(rest));
}
}
}
escape(value)
}
pub(crate) fn requires_text(requires: Requires) -> String {
match requires {
Requires::Abi { major, minor } => format!("plugin ABI {major}.{minor}"),
Requires::PluginApi(n) => format!("plugin API {n}"),
Requires::MicroSchema(n) => format!("micro pack schema {n}"),
Requires::ThemeFormat(n) => format!("theme format {n}"),
_ => "unknown".into(),
}
}
fn requires_json(requires: Requires) -> Value {
match requires {
Requires::Abi { major, minor } => json!({"abi": format!("{major}.{minor}")}),
Requires::PluginApi(n) => json!({"plugin_api": n}),
Requires::MicroSchema(n) => json!({"micro_schema": n}),
Requires::ThemeFormat(n) => json!({"theme_format": n}),
_ => Value::Null,
}
}
pub(crate) fn capabilities_text(version: &ListedVersion) -> String {
version
.capabilities
.iter()
.map(|c| format!("{} {}", c.kind, c.name))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn permissions_text(kind: Kind, version: &ListedVersion) -> String {
if version.permissions.is_empty() {
return match kind {
Kind::WasmPlugin => {
"none (sandboxed: no files, network, clock or processes)".to_string()
}
Kind::Crate => "none declared".to_string(),
_ => "none (data only)".to_string(),
};
}
let mut text = version.permissions.join(", ");
if kind == Kind::Crate {
text.push_str(" (declared, not enforced: native code runs with your rights)");
}
text
}
fn version_json(listing: &Listing, version: &ListedVersion, host: &Host) -> Value {
json!({
"version": version.version.to_string(),
"published": version.published,
"requires": requires_json(version.requires),
"compatible": host.check(listing.kind, version).is_ok(),
"artifact": {
"url": version.artifact.url,
"sha256": version.artifact.sha256,
"size": version.artifact.size,
},
"source_revision": version.source_revision,
"capabilities": version.capabilities.iter()
.map(|c| json!({"kind": c.kind, "name": c.name})).collect::<Vec<_>>(),
"permissions": version.permissions,
"yanked": version.yanked.is_some(),
"yank_reason": version.yanked,
})
}
fn listing_json(listing: &Listing, host: &Host, installed: Option<&LockEntry>) -> Value {
let candidate = resolve(listing, host, &Request::Newest).ok();
json!({
"id": listing.id,
"kind": listing.kind.as_str(),
"name": listing.name,
"description": listing.description,
"tier": listing.tier.as_str(),
"tier_label": tier_display(listing.tier),
"recommended": listing.recommended.as_ref().map(|r| json!({"since": r.since, "note": r.note})),
"license": listing.license,
"authors": listing.authors,
"repository": listing.repository,
"homepage": listing.homepage,
"keywords": listing.keywords,
"crate": listing.crate_info.as_ref().map(|c| json!({"name": c.name, "features": c.features})),
"installable": listing.kind.is_installable(),
"candidate": candidate.map(|v| v.version.to_string()),
"installed": installed.map(|e| e.version.clone()),
"versions": listing.versions.iter().map(|v| version_json(listing, v, host)).collect::<Vec<_>>(),
})
}
pub(crate) fn cargo_add(listing: &Listing, version: &ListedVersion) -> String {
let name = listing
.crate_info
.as_ref()
.map_or(listing.id.as_str(), |c| c.name.as_str());
let mut command = format!("cargo add {name}@={}", version.version);
if let Some(features) = listing.crate_info.as_ref().map(|c| &c.features)
&& !features.is_empty()
{
command.push_str(&format!(" --features {}", features.join(",")));
}
command
}
fn crate_refusal(listing: &Listing) -> Failure {
let version = resolve(listing, &Host::current(), &Request::Newest)
.ok()
.or_else(|| listing.versions.first());
let name = listing
.crate_info
.as_ref()
.map_or(listing.id.as_str(), |c| c.name.as_str());
match version {
Some(version) => usage(format!(
"{} is a Cargo crate ({name} {}); add it to your program:\n {}",
listing.id,
version.version,
cargo_add(listing, version)
)),
None => usage(format!(
"{} is a Cargo crate ({name}), which is listed only and never installed",
listing.id
)),
}
}
fn grid(rows: &[(&str, String)], no_color: bool) -> String {
let mut table = rich::Table::grid().padding(0, 2, 0, 0);
table.add_column("");
table.add_column_with(
Text::default(),
rich::ColumnOptions {
overflow: rich::Overflow::Fold,
..rich::ColumnOptions::default()
},
);
for (key, value) in rows {
let value: Vec<String> = value.lines().map(|l| row_markup(key, l)).collect();
table.add_row(&[&format!("[bold]{key}[/]"), &value.join("\n")]);
}
cli_spec::render(&table, no_color) + "\n"
}
fn table(columns: &[&str], rows: &[Vec<String>], no_color: bool) -> String {
let mut table = rich::Table::new();
for column in columns {
table.add_column(*column);
}
for row in rows {
let cells: Vec<&str> = row.iter().map(String::as_str).collect();
table.add_row(&cells);
}
cli_spec::render(&table, no_color) + "\n"
}
pub(crate) fn provenance(
listing: &Listing,
version: &ListedVersion,
) -> Vec<(&'static str, String)> {
let mut rows = vec![
(
"Package",
format!("{} {} ({})", listing.id, version.version, listing.kind),
),
("Name", listing.name.clone()),
(
"Tier",
format!(
"{}: {}",
tier_label(listing.tier),
tier_meaning(listing.tier)
),
),
("Publisher", publisher(listing)),
("Authored by", authors_text(listing)),
("Source", listing.repository.clone()),
];
if let Some(recommended) = &listing.recommended {
rows.push(("Recommended", recommended_text(recommended)));
}
if let Some(revision) = &version.source_revision {
rows.push(("Revision", revision.clone()));
}
rows.push(("Artifact", version.artifact.url.clone()));
rows.push(("SHA-256", version.artifact.sha256.clone()));
rows.push(("Size", format!("{} bytes", version.artifact.size)));
rows.push(("Requires", requires_text(version.requires)));
rows.push(("Capabilities", capabilities_text(version)));
rows.push(("Permissions", permissions_text(listing.kind, version)));
rows.push(("License", listing.license.clone()));
if let Some(reason) = &version.yanked {
rows.push(("Yanked", reason.clone()));
}
rows
}
pub(crate) fn confirm(question: &str, options: &Options) -> Outcome<bool> {
if options.yes {
return Ok(true);
}
if !std::io::stdin().is_terminal() {
return Err(input(format!(
"{question} needs an answer, and there is no terminal to ask on; pass --yes"
)));
}
eprint!("{question} [y/N] ");
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| input(format!("cannot read the answer: {e}")))?;
Ok(matches!(
answer.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
fn lockfile(dir: &Path) -> Outcome<Lockfile> {
Lockfile::load(&dir.join(LOCKFILE_NAME)).map_err(lockfile_failure)
}
pub(crate) const MARKETPLACE_SITE: &str =
"https://buchochelliq-labs.github.io/rs-rich-marketplace/";
pub(crate) const MARKETPLACE_PAGE: &str =
"https://buchochelliq-labs.github.io/rs-rich-cli/latest/marketplace/";
pub(crate) fn search_matches(listing: &Listing, query: &str, options: &Options) -> bool {
if options.kind.is_some_and(|k| k != listing.kind)
|| options.tier.is_some_and(|t| t != listing.tier)
|| (options.recommended && listing.recommended.is_none())
{
return false;
}
let mut fields = vec![
listing.id.as_str(),
listing.name.as_str(),
listing.description.as_str(),
listing.kind.as_str(),
];
fields.extend(listing.keywords.iter().map(String::as_str));
fields.extend(listing.authors.iter().map(String::as_str));
if let Some(info) = &listing.crate_info {
fields.push(info.name.as_str());
}
for version in &listing.versions {
for capability in &version.capabilities {
fields.push(capability.kind.as_str());
fields.push(capability.name.as_str());
}
}
let haystack = fields.join("\n").to_lowercase();
query.split_whitespace().all(|word| haystack.contains(word))
}
fn search(options: &Options) -> Outcome<Done> {
let dir = require_dir(options)?;
let mut state = State::load(&dir)?;
let market = open(options, &dir, &mut state)?;
let lock = lockfile(&dir)?;
let query = options.positionals.join(" ").to_lowercase();
let host = Host::current();
let by_downloads = options.sort.as_deref() == Some("downloads");
let stats = if by_downloads {
let stats = stats::load(&market.location);
if stats.is_none() && !options.json {
eprintln!(
"rich: the marketplace's stats.json is not available; sorted by id ({})",
stats::stats_source(&market.location)
);
}
stats
} else {
None
};
let mut listings = market.listings()?;
if let Some(stats) = &stats {
listings.sort_by(|a, b| {
let (x, y) = (stats.total(&a.id), stats.total(&b.id));
y.cmp(&x).then_with(|| a.id.cmp(&b.id))
});
}
let mut rows = Vec::new();
let mut found = Vec::new();
for listing in listings {
if !search_matches(&listing, &query, options) {
continue;
}
let installed = lock.get(&listing.id);
let candidate = resolve(&listing, &host, &Request::Newest)
.map(|v| v.version.to_string())
.unwrap_or_else(|_| "none for this host".into());
let mut row = vec![
escape(&listing.id),
escape(listing.kind.as_str()),
marks(&listing),
escape(&candidate),
escape(&installed.map_or_else(String::new, |e| e.version.clone())),
];
let mut value = listing_json(&listing, &host, installed);
if let Some(stats) = &stats {
row.push(
stats
.total(&listing.id)
.map_or_else(|| "-".to_string(), |n| n.to_string()),
);
value["stats"] = stats.package_json(&listing.id);
}
row.push(format!(
"{}\n[dim]{}[/]",
escape(&listing.description),
escape(&authored_by(&listing))
));
rows.push(row);
found.push(value);
}
let mut columns = vec!["Id", "Kind", "Tier", "Version", "Installed"];
if stats.is_some() {
columns.push("Downloads");
}
columns.push("Description");
let text = if rows.is_empty() {
format!(
"No packages match. Try fewer words, or browse every package with `rich plugin \
browse`, or on the web: {MARKETPLACE_SITE} (or the docs' {MARKETPLACE_PAGE})\n"
)
} else {
table(&columns, &rows, options.no_color)
};
Ok(Done::new(
text,
json!({
"marketplace": market.location.shown(),
"index_serial": market.index.serial(),
"packages": found,
}),
))
}
fn one_id(options: &Options, what: &str) -> Outcome<String> {
match options.positionals.as_slice() {
[id] => Ok(id.clone()),
[] => Err(usage(format!("rich plugin {what} needs a package ID"))),
_ => Err(usage(format!("rich plugin {what} takes one package ID"))),
}
}
fn info(options: &Options) -> Outcome<Done> {
let id = one_id(options, "info")?;
let dir = require_dir(options)?;
let mut state = State::load(&dir)?;
let market = open(options, &dir, &mut state)?;
let listing = market.listing(&id)?;
let lock = lockfile(&dir)?;
let installed = lock.get(&id);
if let Some(entry) = installed {
let listed = listing
.versions
.iter()
.find(|v| v.version.to_string() == entry.version);
state.note_yank(&id, &entry.version, listed);
let _ = state.save(&dir);
}
let host = Host::current();
let candidate = resolve(&listing, &host, &Request::Newest);
let mut rows = vec![
("Name", listing.name.clone()),
("Kind", listing.kind.to_string()),
(
"Tier",
format!(
"{}: {}",
tier_label(listing.tier),
tier_meaning(listing.tier)
),
),
("Publisher", publisher(&listing)),
("Authored by", authors_text(&listing)),
];
if let Some(recommended) = &listing.recommended {
rows.push(("Recommended", recommended_text(recommended)));
}
rows.extend([
("Description", listing.description.clone()),
("License", listing.license.clone()),
("Repository", listing.repository.clone()),
]);
if let Some(homepage) = &listing.homepage {
rows.push(("Homepage", homepage.clone()));
}
match &candidate {
Ok(version) => {
rows.push(("Newest", format!("{} (suits this host)", version.version)));
rows.push(("Capabilities", capabilities_text(version)));
rows.push(("Permissions", permissions_text(listing.kind, version)));
if listing.kind == Kind::Crate {
rows.push(("Add with", cargo_add(&listing, version)));
}
}
Err(error) => rows.push(("Newest", error.to_string())),
}
if let Some(entry) = installed {
rows.push(("Installed", entry.version.clone()));
}
let stats = stats::load(&market.location);
if let Some(stats) = &stats {
let versions: Vec<String> = listing
.versions
.iter()
.map(|v| v.version.to_string())
.collect();
rows.extend(stats.rows(&listing.id, &versions));
}
let mut text = format!("{}\n\n", line(&listing.id));
text.push_str(&grid(&rows, options.no_color));
let versions: Vec<Vec<String>> = listing
.versions
.iter()
.map(|v| {
[
v.version.to_string(),
v.published.clone(),
requires_text(v.requires),
match host.check(listing.kind, v) {
Ok(()) => "yes".into(),
Err(reason) => reason.to_string(),
},
v.yanked.clone().unwrap_or_default(),
]
.iter()
.map(|cell| escape(cell))
.collect()
})
.collect();
text.push('\n');
text.push_str(&table(
&["Version", "Published", "Requires", "Loads here", "Yanked"],
&versions,
options.no_color,
));
let mut result = listing_json(&listing, &host, installed);
result["stats"] = stats.map_or(Value::Null, |s| s.package_json(&listing.id));
Ok(Done::new(text, result))
}
fn requested_version(arg: &str) -> Outcome<(String, Request)> {
match arg.split_once('@') {
None => Ok((arg.to_string(), Request::Newest)),
Some((id, version)) => {
let version = rich_marketplace::semver::Version::parse(version).map_err(|e| {
usage(format!(
"{:?}: not a version: {e}",
controls::shown(version)
))
})?;
Ok((id.to_string(), Request::Exact(version)))
}
}
}
fn entry_json(entry: &LockEntry) -> Value {
json!({
"id": entry.id,
"kind": entry.kind.as_str(),
"version": entry.version,
"tier": entry.tier.as_str(),
"tier_label": tier_display(entry.tier),
"url": entry.url,
"sha256": entry.sha256,
"size": entry.size,
"index_serial": entry.index_serial,
"path": entry.path,
})
}
fn install_version(
options: &Options,
dir: &Path,
market: &Market,
listing: &Listing,
version: &ListedVersion,
verb: &str,
) -> Outcome<Option<LockEntry>> {
if listing.kind == Kind::WasmPlugin && !RuntimeKind::Wasm.supported() {
return Err(usage(format!(
"{} is a WASM plugin, and this build cannot load WASM plugins; install rs-rich-cli \
with --features wasm-plugins",
listing.id
)));
}
if !options.json || !options.yes {
eprint!("{}", grid(&provenance(listing, version), options.no_color));
}
let question = format!(
"{verb} {} {} into {}?",
listing.id,
version.version,
dir.display()
);
if !confirm(&question, options)? {
return Ok(None);
}
let installer = Installer::new(dir, market.location.transport()?).with_checker(SandboxChecker);
let entry = installer
.install(&market.index, listing, version, SystemTime::now())
.map_err(install_failure)?;
Ok(Some(entry))
}
fn install(options: &Options) -> Outcome<Done> {
if options.positionals.is_empty() {
return Err(usage("rich plugin install needs a package ID"));
}
let requests = options
.positionals
.iter()
.map(|arg| requested_version(arg))
.collect::<Outcome<Vec<_>>>()?;
let dir = require_dir(options)?;
let mut state = State::load(&dir)?;
let market = open(options, &dir, &mut state)?;
let host = Host::current();
let mut text = String::new();
let mut installed = Vec::new();
for (id, request) in requests {
let listing = market.listing(&id)?;
if !listing.kind.is_installable() {
return Err(crate_refusal(&listing));
}
let version = resolve(&listing, &host, &request).map_err(resolve_failure)?;
let current = lockfile(&dir)?;
if let Some(entry) = current.get(&id)
&& entry.version == version.version.to_string()
&& entry.sha256 == version.artifact.sha256
&& entry.verify(&dir).is_ok()
{
text.push_str(&format!("{id} {} is already installed\n", entry.version));
installed.push(entry_json(entry));
continue;
}
match install_version(options, &dir, &market, &listing, version, "Install")? {
Some(entry) => {
state = State::load(&dir)?;
state.note_yank(&id, &entry.version, None);
state.save(&dir)?;
text.push_str(&format!(
"Installed {} {} ({}, {})\n",
entry.id, entry.version, entry.kind, entry.tier
));
installed.push(entry_json(&entry));
}
None => {
let mut done = Done::new(text, json!({"installed": installed}));
done.declined = true;
return Ok(done);
}
}
}
Ok(Done::new(text, json!({"installed": installed})))
}
fn update(options: &Options) -> Outcome<Done> {
let dir = require_dir(options)?;
let lock = lockfile(&dir)?;
let ids: Vec<String> = if options.positionals.is_empty() {
lock.entries().map(|e| e.id.clone()).collect()
} else {
for id in &options.positionals {
if lock.get(id).is_none() {
return Err(usage(format!(
"{} is not installed; `rich plugin install {}` installs it",
controls::shown(id),
controls::shown(id)
)));
}
}
options.positionals.clone()
};
let mut state = State::load(&dir)?;
let market = open(options, &dir, &mut state)?;
let host = Host::current();
let mut text = String::new();
let (mut updated, mut current, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
for id in ids {
let Some(entry) = lock.get(&id) else { continue };
let listing = match market.listing(&id) {
Ok(listing) => listing,
Err(failure) if failure.class == ExitClass::Usage => {
text.push_str(&format!(
"{id}: no longer listed; kept at {}\n",
entry.version
));
skipped.push(json!({"id": id, "reason": "not listed"}));
continue;
}
Err(failure) => return Err(failure),
};
let listed = listing
.versions
.iter()
.find(|v| v.version.to_string() == entry.version);
let yanked = listed.and_then(|v| v.yanked.clone());
state.note_yank(&id, &entry.version, listed);
state.save(&dir)?;
let candidate = match resolve(&listing, &host, &Request::Newest) {
Ok(candidate) => candidate,
Err(error) => {
text.push_str(&format!("{id}: {error}; kept at {}\n", entry.version));
skipped.push(json!({"id": id, "reason": error.to_string()}));
continue;
}
};
let installed = rich_marketplace::semver::Version::parse(&entry.version).ok();
let newer = installed.as_ref().is_none_or(|v| candidate.version > *v);
let replace_yanked = yanked.is_some() && candidate.version.to_string() != entry.version;
if !newer && !replace_yanked {
text.push_str(&format!("{id} {} is up to date\n", entry.version));
current.push(json!({"id": id, "version": entry.version}));
continue;
}
match install_version(options, &dir, &market, &listing, candidate, "Update to")? {
Some(new) => {
state = State::load(&dir)?;
state.note_yank(&id, &new.version, None);
state.save(&dir)?;
text.push_str(&format!(
"Updated {id} {} -> {}\n",
entry.version, new.version
));
updated.push(json!({"id": id, "from": entry.version, "to": new.version}));
}
None => {
let mut done = Done::new(
text,
json!({"updated": updated, "current": current, "skipped": skipped}),
);
done.declined = true;
return Ok(done);
}
}
}
if text.is_empty() {
text.push_str("Nothing is installed.\n");
}
Ok(Done::new(
text,
json!({"updated": updated, "current": current, "skipped": skipped}),
))
}
fn remove(options: &Options) -> Outcome<Done> {
if options.positionals.is_empty() {
return Err(usage("rich plugin remove needs a package ID"));
}
let dir = require_dir(options)?;
let installer = Installer::new(&dir, rich_marketplace::MemoryTransport::new());
let mut state = State::load(&dir)?;
let mut text = String::new();
let mut removed = Vec::new();
for id in &options.positionals {
let entry = installer.remove(id).map_err(lockfile_failure)?;
state.set_enabled(id, true);
state.yanked.remove(id);
text.push_str(&format!("Removed {} {}\n", entry.id, entry.version));
removed.push(entry_json(&entry));
}
state.save(&dir)?;
Ok(Done::new(text, json!({"removed": removed})))
}
fn file_status(dir: &Path, entry: &LockEntry) -> (&'static str, Option<String>) {
match entry.verify(dir) {
Ok(_) => ("ok", None),
Err(LockfileError::Missing { .. }) => ("missing", Some("the file is missing".into())),
Err(LockfileError::Mismatch { .. }) => (
"changed",
Some("the file changed on disk; it is not loaded".into()),
),
Err(other) => ("error", Some(other.to_string())),
}
}
fn list(options: &Options) -> Outcome<Done> {
let dir = require_dir(options)?;
let lock = lockfile(&dir)?;
let state = State::load(&dir)?;
let mut rows = Vec::new();
let mut packages = Vec::new();
for entry in lock.entries() {
let enabled = state.is_enabled(&entry.id);
let (status, problem) = file_status(&dir, entry);
let yanked = state.yank_reason(entry);
let mut note = problem.clone().unwrap_or_default();
if let Some(reason) = yanked {
if !note.is_empty() {
note.push_str("; ");
}
note.push_str(&format!("yanked: {reason}"));
}
rows.push(vec![
escape(&entry.id),
escape(&entry.version),
escape(entry.kind.as_str()),
tier_marker(entry.tier),
if enabled { "yes" } else { "no" }.into(),
status.into(),
escape(¬e),
]);
let mut value = entry_json(entry);
value["enabled"] = json!(enabled);
value["status"] = json!(status);
value["yank_reason"] = json!(yanked);
packages.push(value);
}
let text = if rows.is_empty() {
format!("Nothing is installed in {}.\n", dir.display())
} else {
table(
&["Id", "Version", "Kind", "Tier", "Enabled", "File", "Notes"],
&rows,
options.no_color,
)
};
Ok(Done::new(
text,
json!({"plugin_dir": dir.display().to_string(), "packages": packages}),
))
}
fn set_enabled(options: &Options, enabled: bool) -> Outcome<Done> {
let verb = if enabled { "enable" } else { "disable" };
if options.positionals.is_empty() {
return Err(usage(format!("rich plugin {verb} needs a package ID")));
}
let dir = require_dir(options)?;
let lock = lockfile(&dir)?;
let mut state = State::load(&dir)?;
let mut text = String::new();
let mut changed = Vec::new();
for id in &options.positionals {
if lock.get(id).is_none() {
return Err(usage(format!("{} is not installed", controls::shown(id))));
}
state.set_enabled(id, enabled);
text.push_str(&format!(
"{} {id}\n",
if enabled { "Enabled" } else { "Disabled" }
));
changed.push(json!({"id": id, "enabled": enabled}));
}
state.save(&dir)?;
Ok(Done::new(text, json!({"packages": changed})))
}
fn verify(options: &Options) -> Outcome<Done> {
let dir = require_dir(options)?;
let lock = lockfile(&dir)?;
let mut problems = Vec::new();
let mut yanked = Vec::new();
let mut checked = 0;
for entry in lock.entries() {
checked += 1;
if let (_, Some(problem)) = file_status(&dir, entry) {
problems.push((entry.id.clone(), problem));
}
}
let mut serial = None;
let mut index_problem = None;
if lock.entries().next().is_some() {
let mut state = State::load(&dir)?;
match open(options, &dir, &mut state) {
Ok(market) => {
serial = Some(market.index.serial());
for entry in lock.entries() {
let listing = match market.listing(&entry.id) {
Ok(listing) => listing,
Err(failure) => {
problems.push((entry.id.clone(), failure.message));
continue;
}
};
let listed = listing
.versions
.iter()
.find(|v| v.version.to_string() == entry.version);
state.note_yank(&entry.id, &entry.version, listed);
match listed {
None => problems.push((
entry.id.clone(),
format!("version {} is no longer listed", entry.version),
)),
Some(v) if v.artifact.sha256 != entry.sha256 => problems.push((
entry.id.clone(),
format!(
"the listing's SHA-256 for {} is {}, the lockfile records {}",
entry.version, v.artifact.sha256, entry.sha256
),
)),
Some(v) => {
if let Some(reason) = &v.yanked {
yanked.push((
entry.id.clone(),
entry.version.clone(),
reason.clone(),
));
}
}
}
}
state.save(&dir)?;
}
Err(failure) => index_problem = Some(failure),
}
}
let mut text = String::new();
if checked == 0 {
text.push_str("Nothing is installed.\n");
} else {
text.push_str(&format!(
"Checked {checked} installed package{}{}.\n",
if checked == 1 { "" } else { "s" },
serial.map_or_else(String::new, |s| format!(" against index serial {s}"))
));
}
for (id, version, reason) in &yanked {
text.push_str(&format!(
"{id} {version} is yanked: {}; `rich plugin update {id}` moves to the newest version\n",
line(reason)
));
}
for (id, problem) in &problems {
text.push_str(&format!("{id}: {}\n", line(problem)));
}
if let Some(failure) = &index_problem {
text.push_str(&format!(
"The marketplace index was not checked: {}\n",
line(&failure.message)
));
}
let result = json!({
"checked": checked,
"index_serial": serial,
"problems": problems.iter().map(|(id, p)| json!({"id": id, "problem": p})).collect::<Vec<_>>(),
"yanked": yanked.iter().map(|(id, v, r)| json!({"id": id, "version": v, "reason": r})).collect::<Vec<_>>(),
"index_error": index_problem.as_ref().map(|f| f.message.clone()),
});
let mut done = Done::new(text, result);
if !problems.is_empty() {
done.problem = Some(data(format!(
"{} installed package{} failed verification",
problems.len(),
if problems.len() == 1 { "" } else { "s" }
)));
} else if let Some(failure) = index_problem {
done.problem = Some(failure);
}
Ok(done)
}
#[cfg(feature = "interact")]
pub(crate) fn run_on(options: &Options, command: &str, id: &str) -> Outcome<Done> {
let options = Options {
command: command.to_string(),
positionals: vec![id.to_string()],
json: false,
no_color: options.no_color,
yes: options.yes,
marketplace: options.marketplace.clone(),
plugin_dir: options.plugin_dir.clone(),
keys: options.keys.clone(),
sort: None,
kind: None,
tier: None,
recommended: false,
publish: BTreeMap::new(),
};
match command {
"install" => install(&options),
"update" => update(&options),
"remove" => remove(&options),
"enable" => set_enabled(&options, true),
"disable" => set_enabled(&options, false),
_ => info(&options),
}
}
#[cfg(feature = "interact")]
pub(crate) fn open_for_browse(options: &Options) -> Outcome<(PathBuf, Market, Lockfile, State)> {
let dir = require_dir(options)?;
let mut state = State::load(&dir)?;
let market = open(options, &dir, &mut state)?;
let lock = lockfile(&dir)?;
Ok((dir, market, lock, state))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
#[test]
fn locations() {
assert_eq!(
Location::parse("https://example.test/m").unwrap(),
Location::Remote("https://example.test/m/".into())
);
assert_eq!(
Location::parse("file:///tmp/a%20b").unwrap(),
Location::Local(PathBuf::from("/tmp/a b"))
);
assert!(Location::parse("http://example.test/").is_err());
assert!(matches!(
Location::parse("some/dir").unwrap(),
Location::Local(_)
));
}
#[test]
fn the_mirror_stays_inside_its_directory() {
let mirror = ArtifactTransport::Mirror(PathBuf::from("/nonexistent-mirror"));
for url in [
"https://example.test/../etc/passwd",
"https://example.test//x",
"https://example.test/a\\b",
] {
assert!(
matches!(mirror.get(url, 10), Err(FetchError::NotFound { .. })),
"{url}"
);
}
assert!(matches!(
mirror.get("http://example.test/x", 10),
Err(FetchError::NotHttps { .. })
));
}
#[test]
fn options_parse() {
let options = parse(&args(&[
"plugin",
"install",
"shout@0.2.0",
"--yes",
"--marketplace",
"m",
]))
.unwrap();
assert_eq!(options.command, "install");
assert_eq!(options.positionals, ["shout@0.2.0"]);
assert!(options.yes);
assert!(parse(&args(&["plugin", "frob"])).is_err());
assert!(parse(&args(&["plugin"])).is_err());
assert!(parse(&args(&["plugin", "list", "--url", "x"])).is_err());
let (id, request) = requested_version("shout@0.2.0").unwrap();
assert_eq!(id, "shout");
assert!(matches!(request, Request::Exact(_)));
assert!(requested_version("shout@x").is_err());
}
#[test]
fn tier_markers() {
assert_eq!(tier_marker(Tier::Official), "[bold green]✓ official[/]");
assert_eq!(tier_marker(Tier::Reviewed), "approved");
assert_eq!(tier_marker(Tier::Community), "[yellow]community[/]");
assert_eq!(
row_markup("Tier", "community: [b]x[/b]"),
"[yellow]community[/]: \\[b]x\\[/b]"
);
assert_eq!(row_markup("Name", "✓ official"), "✓ official");
assert_eq!(
row_markup("Recommended", "★ recommended since x: [i]"),
"[bold magenta]★ recommended[/] since x: \\[i]"
);
assert_eq!(repository_owner("https://github.com/ada/x.git"), "ada");
assert_eq!(repository_owner("https://example.test"), "example.test");
assert_eq!(
repository_source("https://github.com/ada/x.git/"),
"github.com/ada/x"
);
let options = parse(&args(&["plugin", "search", "--tier", "reviewed"])).unwrap();
assert_eq!(options.tier, Some(Tier::Reviewed));
assert!(options.publish.is_empty());
assert!(parse(&args(&["plugin", "list", "--tier", "official"])).is_err());
}
#[test]
fn state_round_trips() {
let mut state = State::default();
state.set_enabled("shout", false);
state.markets.insert(
"file:///m".into(),
MarketState {
accepted: Some(AcceptedIndex {
serial: 3,
sha256: "0".repeat(64),
}),
},
);
state
.yanked
.insert("shout".into(), ("0.2.1".into(), "bad".into()));
state.chains.insert(
"057D5BF5EBC4B734".into(),
vec![CachedRotation {
keys_json: "{\"generation\": 2}".into(),
main_signature: None,
backup_signature: "sig".into(),
}],
);
let dir = tempfile::tempdir().unwrap();
state.save(dir.path()).unwrap();
assert_eq!(State::load(dir.path()).unwrap(), state);
assert!(!state.is_enabled("shout"));
}
}