use std::collections::hash_map::DefaultHasher;
use std::fs::{File, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use ossctl_core::ports::{
Clock, CommandOutput, CommandRunner, Fs, GitRepo, IdGen, JournalLock, JournalStore,
RegistryQuery, Tagger,
};
pub struct RealCommandRunner;
impl CommandRunner for RealCommandRunner {
fn run(&self, program: &str, args: &[&str], cwd: &Path) -> io::Result<CommandOutput> {
let out = Command::new(program)
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.env("GH_PROMPT_DISABLED", "1")
.env("GH_NO_UPDATE_NOTIFIER", "1")
.output()?;
Ok(CommandOutput {
status: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
pub struct RealFs;
impl Fs for RealFs {
fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn is_dir(&self, path: &Path) -> bool {
path.is_dir()
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
fn read_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
let mut names = Vec::new();
for entry in std::fs::read_dir(dir)? {
names.push(entry?.file_name().to_string_lossy().into_owned());
}
names.sort();
Ok(names)
}
}
pub struct RealGitRepo {
root: PathBuf,
}
impl RealGitRepo {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn git(&self, args: &[&str]) -> io::Result<std::process::Output> {
Command::new("git")
.arg("-C")
.arg(&self.root)
.args(args)
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.env("GIT_OPTIONAL_LOCKS", "0")
.output()
}
fn git_stdout(&self, args: &[&str]) -> io::Result<String> {
let out = self.git(args)?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(io::Error::other(format!(
"git {} exited {:?}",
args.join(" "),
out.status.code()
)))
}
}
}
impl GitRepo for RealGitRepo {
fn head_commit(&self) -> io::Result<String> {
let head = self.git_stdout(&["rev-parse", "HEAD"])?.trim().to_string();
if head.is_empty() {
return Err(io::Error::other("git rev-parse HEAD produced no output"));
}
Ok(head)
}
fn is_work_tree(&self) -> bool {
self.git(&["rev-parse", "--is-inside-work-tree"])
.is_ok_and(|o| o.status.success())
}
fn shortlog(&self, since: Option<&str>) -> io::Result<String> {
let mut args: Vec<String> = vec!["shortlog".into(), "-sne".into(), "--all".into()];
if let Some(s) = since {
args.push(format!("--since={s}"));
}
args.push("HEAD".into());
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
self.git_stdout(&borrowed)
}
fn tags(&self) -> io::Result<Vec<String>> {
Ok(self
.git_stdout(&["tag", "--list"])?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
fn git_common_dir(&self) -> io::Result<PathBuf> {
let raw = self.git_stdout(&["rev-parse", "--git-common-dir"])?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(io::Error::other(
"git rev-parse --git-common-dir produced no output",
));
}
let path = Path::new(trimmed);
Ok(if path.is_absolute() {
path.to_path_buf()
} else {
self.root.join(path)
})
}
}
pub struct RealClock;
impl Clock for RealClock {
fn now_unix(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
}
pub struct RealIdGen;
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
impl IdGen for RealIdGen {
fn new_id(&self) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let ms = u64::try_from(now.as_millis()).unwrap_or(u64::MAX) & ((1 << 48) - 1);
let counter = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let anchor = 0u8;
let addr = std::ptr::addr_of!(anchor) as usize;
let pid = std::process::id();
let mut h1 = DefaultHasher::new();
(now.subsec_nanos(), counter, ms, addr, pid).hash(&mut h1);
let mut h2 = DefaultHasher::new();
(counter, h1.finish(), addr, ms, pid).hash(&mut h2);
let rand80 = (u128::from(h1.finish()) << 16) | u128::from(h2.finish() & 0xffff);
let value = (u128::from(ms) << 80) | (rand80 & ((1 << 80) - 1));
crockford_u128(value)
}
}
fn crockford_u128(mut value: u128) -> String {
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let mut buf = [0u8; 26];
for slot in buf.iter_mut().rev() {
*slot = ALPHABET[(value & 0x1f) as usize];
value >>= 5;
}
String::from_utf8(buf.to_vec()).expect("crockford alphabet is ASCII")
}
pub struct RealRegistryQuery;
impl RealRegistryQuery {
fn npm_versions(package: &str) -> io::Result<Vec<String>> {
if package.starts_with('-') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to query npm for a package name that looks like a flag: {package:?}"
),
));
}
let out = Command::new("npm")
.args(["view", "--json", package, "versions"])
.stdin(Stdio::null())
.env("NO_UPDATE_NOTIFIER", "1")
.env("NPM_CONFIG_FUND", "false")
.output()?;
if !out.status.success() {
return Err(io::Error::other(format!(
"npm view {package} versions exited {:?}",
out.status.code()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
Ok(serde_json::Value::Array(items)) => items
.into_iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.ok_or_else(|| io::Error::other("npm returned a non-string version entry"))
})
.collect(),
Ok(serde_json::Value::String(v)) => Ok(vec![v]),
_ => Err(io::Error::other(format!(
"could not parse `npm view --json {package} versions` output"
))),
}
}
fn sparse_index_path(crate_name: &str) -> String {
let name = crate_name.to_ascii_lowercase();
match name.len() {
0 => name,
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{}", &name[0..1], name),
_ => format!("{}/{}/{}", &name[0..2], &name[2..4], name),
}
}
const MAX_CRATE_NAME_LEN: usize = 64;
fn validate_crate_name(crate_name: &str) -> io::Result<()> {
if crate_name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"refusing to query crates.io for an empty crate name",
));
}
if crate_name.len() > Self::MAX_CRATE_NAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"refusing to query crates.io for an over-long crate name ({} > {} bytes)",
crate_name.len(),
Self::MAX_CRATE_NAME_LEN
),
));
}
if crate_name.starts_with('-')
|| !crate_name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("refusing to query crates.io for a suspicious crate name: {crate_name:?}"),
));
}
Ok(())
}
fn parse_sparse_index(body: &str, expected_name: &str) -> io::Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct SparseEntry {
name: String,
vers: String,
}
let mut versions = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry: SparseEntry = serde_json::from_str(line).map_err(|e| {
io::Error::other(format!(
"crates.io sparse-index line was not valid JSON: {e}"
))
})?;
if !entry.name.eq_ignore_ascii_case(expected_name) {
return Err(io::Error::other(format!(
"crates.io sparse-index body was for crate {:?}, not the requested {expected_name:?}",
entry.name
)));
}
versions.push(entry.vers);
}
Ok(versions)
}
fn parse_sparse_response(stdout: &str, expected_name: &str) -> io::Result<Vec<String>> {
let (body, code) = stdout.rsplit_once(Self::HTTP_CODE_MARKER).ok_or_else(|| {
io::Error::other("curl did not report an HTTP status for the crates.io sparse index")
})?;
match code.trim() {
"200" => {
let versions = Self::parse_sparse_index(body, expected_name)?;
if versions.is_empty() {
return Err(io::Error::other(
"crates.io sparse index returned HTTP 200 with no release records; \
treating as unknown rather than 'not published'",
));
}
Ok(versions)
}
"404" => Ok(Vec::new()),
other => Err(io::Error::other(format!(
"crates.io sparse index returned an unexpected HTTP status {other}"
))),
}
}
const HTTP_CODE_MARKER: &'static str = "\n__OSSCTL_HTTP_CODE__:";
fn crates_io_versions(crate_name: &str) -> io::Result<Vec<String>> {
Self::validate_crate_name(crate_name)?;
let url = format!(
"https://index.crates.io/{}",
Self::sparse_index_path(crate_name)
);
let write_out = format!("{}%{{http_code}}", Self::HTTP_CODE_MARKER);
let out = Command::new("curl")
.args([
"--disable",
"--silent",
"--show-error",
"--max-time",
"30",
"--user-agent",
concat!("ossctl/", env!("CARGO_PKG_VERSION")),
])
.arg("--write-out")
.arg(&write_out)
.arg(&url)
.stdin(Stdio::null())
.output()
.map_err(|e| {
io::Error::other(format!(
"could not run `curl` to query the crates.io sparse index \
(is curl installed?): {e}"
))
})?;
if !out.status.success() {
return Err(io::Error::other(format!(
"could not reach the crates.io sparse index for {crate_name:?}: curl exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout);
Self::parse_sparse_response(&stdout, crate_name)
}
}
impl RegistryQuery for RealRegistryQuery {
fn published_versions(&self, ecosystem: &str, package: &str) -> io::Result<Vec<String>> {
match ecosystem {
"node" => Self::npm_versions(package),
"rust" => Self::crates_io_versions(package),
other => Err(io::Error::other(format!(
"no registry query wired for ecosystem '{other}' yet"
))),
}
}
}
pub struct ReadOnlyJournalStore;
impl ReadOnlyJournalStore {
fn read_only(op: &str) -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
format!("{op} is not available on the read-only journal store"),
)
}
}
impl JournalStore for ReadOnlyJournalStore {
fn lock_exclusive(&self, _lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
Err(Self::read_only("lock_exclusive"))
}
fn append_line(&self, _path: &Path, _line: &str) -> io::Result<()> {
Err(Self::read_only("append_line"))
}
fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let end = contents.rfind('\n').map_or(0, |i| i + 1);
Ok(contents[..end].lines().map(str::to_string).collect())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn write_atomic(&self, _path: &Path, _bytes: &[u8]) -> io::Result<()> {
Err(Self::read_only("write_atomic"))
}
fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
let mut names = Vec::new();
match std::fs::read_dir(dir) {
Ok(entries) => {
for entry in entries {
names.push(entry?.file_name().to_string_lossy().into_owned());
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
}
names.sort();
Ok(names)
}
}
pub struct RealTagger {
root: PathBuf,
}
impl RealTagger {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn run(&self, program: &str, args: &[&str]) -> io::Result<std::process::Output> {
Command::new(program)
.args(args)
.current_dir(&self.root)
.stdin(Stdio::null())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.env("GH_PROMPT_DISABLED", "1")
.env("GH_NO_UPDATE_NOTIFIER", "1")
.output()
}
fn check(out: std::process::Output, what: &str) -> io::Result<std::process::Output> {
if out.status.success() {
return Ok(out);
}
let stderr = String::from_utf8_lossy(&out.stderr);
let detail = if stderr.trim().is_empty() {
String::from_utf8_lossy(&out.stdout).into_owned()
} else {
stderr.into_owned()
};
Err(io::Error::other(format!(
"{what} exited {:?}: {}",
out.status.code(),
detail.trim()
)))
}
fn tag_commit(&self, tag: &str) -> Option<String> {
let out = self
.run(
"git",
&["rev-parse", "--verify", "-q", &format!("{tag}^{{commit}}")],
)
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
}
impl Tagger for RealTagger {
fn create_tag(&self, tag: &str, commit: &str, message: &str) -> io::Result<()> {
if let Some(existing) = self.tag_commit(tag) {
if existing == commit || existing.starts_with(commit) || commit.starts_with(&existing) {
return Ok(());
}
return Err(io::Error::other(format!(
"tag `{tag}` already exists at {existing}, not the sealed commit {commit}"
)));
}
let out = self.run("git", &["tag", "-a", tag, commit, "-m", message])?;
Self::check(out, "git tag")?;
Ok(())
}
fn push_tag(&self, tag: &str) -> io::Result<()> {
let refspec = format!("refs/tags/{tag}:refs/tags/{tag}");
let out = self.run("git", &["push", "origin", &refspec])?;
Self::check(out, "git push")?;
Ok(())
}
fn create_github_release(&self, tag: &str, title: &str) -> io::Result<Option<String>> {
let view = self.run(
"gh",
&["release", "view", tag, "--json", "url", "-q", ".url"],
)?;
if view.status.success() {
let url = String::from_utf8_lossy(&view.stdout).trim().to_string();
return Ok((!url.is_empty()).then_some(url));
}
let out = self.run(
"gh",
&[
"release",
"create",
tag,
"--title",
title,
"--generate-notes",
],
)?;
let out = Self::check(out, "gh release create")?;
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok((!url.is_empty()).then_some(url))
}
}
pub struct RealJournalStore;
struct RealJournalLock {
path: PathBuf,
}
impl JournalLock for RealJournalLock {}
impl Drop for RealJournalLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn fsync_dir(dir: &Path) {
let _ = File::open(dir).and_then(|f| f.sync_all());
}
impl JournalStore for RealJournalStore {
fn lock_exclusive(&self, lock_path: &Path) -> io::Result<Box<dyn JournalLock>> {
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
match OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(mut f) => {
let _ = writeln!(f, "{}", std::process::id());
let _ = f.sync_all();
Ok(Box::new(RealJournalLock {
path: lock_path.to_path_buf(),
}))
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::new(
io::ErrorKind::WouldBlock,
"another release cut/resume holds the single-active-cut lock",
)),
Err(e) => Err(e),
}
}
fn append_line(&self, path: &Path, line: &str) -> io::Result<()> {
let created_parent = match path.parent() {
Some(p) if !p.exists() => {
std::fs::create_dir_all(p)?;
true
}
_ => false,
};
let mut f = OpenOptions::new().create(true).append(true).open(path)?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
if created_parent {
if let Some(p) = path.parent() {
fsync_dir(p);
}
}
Ok(())
}
fn read_lines(&self, path: &Path) -> io::Result<Vec<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s.lines().map(str::to_string).collect()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn write_atomic(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "manifest path has no parent")
})?;
std::fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manifest.json");
let counter = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = parent.join(format!(".{file_name}.{}.{counter}.tmp", std::process::id()));
{
let mut f = File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
fsync_dir(parent);
Ok(())
}
fn list_dir(&self, dir: &Path) -> io::Result<Vec<String>> {
match std::fs::read_dir(dir) {
Ok(entries) => {
let mut names = Vec::new();
for entry in entries {
names.push(entry?.file_name().to_string_lossy().into_owned());
}
names.sort();
Ok(names)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sparse_index_path_follows_cargo_prefix_convention() {
assert_eq!(RealRegistryQuery::sparse_index_path("a"), "1/a");
assert_eq!(RealRegistryQuery::sparse_index_path("no"), "2/no");
assert_eq!(RealRegistryQuery::sparse_index_path("cfg"), "3/c/cfg");
assert_eq!(RealRegistryQuery::sparse_index_path("serde"), "se/rd/serde");
assert_eq!(
RealRegistryQuery::sparse_index_path("ossctl-core"),
"os/sc/ossctl-core"
);
assert_eq!(
RealRegistryQuery::sparse_index_path("Ossctl-Core"),
"os/sc/ossctl-core"
);
}
#[test]
fn parse_sparse_index_collects_versions_including_yanked() {
let body = concat!(
r#"{"name":"tool","vers":"0.9.0","yanked":false}"#,
"\n",
r#"{"name":"tool","vers":"1.0.0","yanked":true}"#,
"\n",
r#"{"name":"tool","vers":"1.1.0","yanked":false}"#,
"\n",
);
let versions = RealRegistryQuery::parse_sparse_index(body, "tool").unwrap();
assert_eq!(versions, vec!["0.9.0", "1.0.0", "1.1.0"]);
}
#[test]
fn parse_sparse_index_blank_lines_are_skipped() {
let body = "\n{\"name\":\"tool\",\"vers\":\"1.0.0\",\"yanked\":false}\n\n";
assert_eq!(
RealRegistryQuery::parse_sparse_index(body, "tool").unwrap(),
vec!["1.0.0"]
);
}
#[test]
fn parse_sparse_index_rejects_malformed_line() {
let body = r#"{"name":"tool","yanked":false}"#;
assert!(RealRegistryQuery::parse_sparse_index(body, "tool").is_err());
let body = r#"{"vers":"1.0.0","yanked":false}"#;
assert!(RealRegistryQuery::parse_sparse_index(body, "tool").is_err());
assert!(RealRegistryQuery::parse_sparse_index("not json", "tool").is_err());
}
#[test]
fn parse_sparse_index_rejects_a_wrong_crate_body() {
let body = r#"{"name":"other","vers":"1.0.0","yanked":false}"#;
assert!(RealRegistryQuery::parse_sparse_index(body, "tool").is_err());
let body = r#"{"name":"Tool","vers":"1.0.0","yanked":false}"#;
assert_eq!(
RealRegistryQuery::parse_sparse_index(body, "tool").unwrap(),
vec!["1.0.0"]
);
}
#[test]
fn parse_sparse_response_200_returns_versions() {
let stdout = format!(
"{}{}200",
r#"{"name":"tool","vers":"1.0.0","yanked":false}"#,
RealRegistryQuery::HTTP_CODE_MARKER
);
assert_eq!(
RealRegistryQuery::parse_sparse_response(&stdout, "tool").unwrap(),
vec!["1.0.0"]
);
}
#[test]
fn parse_sparse_response_404_is_empty_not_error() {
let stdout = format!("{}404", RealRegistryQuery::HTTP_CODE_MARKER);
assert_eq!(
RealRegistryQuery::parse_sparse_response(&stdout, "tool").unwrap(),
Vec::<String>::new()
);
}
#[test]
fn parse_sparse_response_empty_200_fails_closed() {
let stdout = format!("{}200", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
let stdout = format!(" \n {}200", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
}
#[test]
fn parse_sparse_response_unreachable_fails_closed() {
let stdout = format!("{}000", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
let stdout = format!("{}503", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
let stdout = format!("{}410", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
let stdout = format!("{}301", RealRegistryQuery::HTTP_CODE_MARKER);
assert!(RealRegistryQuery::parse_sparse_response(&stdout, "tool").is_err());
assert!(RealRegistryQuery::parse_sparse_response("some body, no marker", "tool").is_err());
}
#[test]
fn validate_crate_name_rejects_suspicious_input() {
assert!(RealRegistryQuery::validate_crate_name("ossctl-core").is_ok());
assert!(RealRegistryQuery::validate_crate_name("serde_json").is_ok());
assert!(RealRegistryQuery::validate_crate_name(
&"a".repeat(RealRegistryQuery::MAX_CRATE_NAME_LEN)
)
.is_ok());
assert!(RealRegistryQuery::validate_crate_name(
&"a".repeat(RealRegistryQuery::MAX_CRATE_NAME_LEN + 1)
)
.is_err());
assert!(RealRegistryQuery::validate_crate_name("").is_err());
assert!(RealRegistryQuery::validate_crate_name("-oops").is_err());
assert!(RealRegistryQuery::validate_crate_name("a/b").is_err());
assert!(RealRegistryQuery::validate_crate_name("a b").is_err());
assert!(RealRegistryQuery::validate_crate_name("a.b").is_err());
}
}