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::{Duration, SystemTime, UNIX_EPOCH};
use shipshape_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 RealGitRepo {
pub fn origin_url(&self) -> io::Result<String> {
Ok(self
.git_stdout(&["remote", "get-url", "origin"])?
.trim()
.to_string())
}
pub fn is_dirty(&self) -> io::Result<bool> {
Ok(!self
.git_stdout(&["status", "--porcelain", "--untracked-files=no"])?
.trim()
.is_empty())
}
}
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;
static E2E_CLOCK_OFFSET_SECS: AtomicU64 = AtomicU64::new(0);
impl Clock for RealClock {
fn now_unix(&self) -> u64 {
let wall = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
wall.saturating_add(E2E_CLOCK_OFFSET_SECS.load(Ordering::Relaxed))
}
fn sleep(&self, dur: Duration) {
if cfg!(debug_assertions) && std::env::var_os("SHIPSHAPE_E2E_FAST_CLOCK").is_some() {
E2E_CLOCK_OFFSET_SECS.fetch_add(dur.as_secs(), Ordering::Relaxed);
} else {
std::thread::sleep(dur);
}
}
}
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;
struct VersionKeys(Vec<String>);
impl<'de> serde::Deserialize<'de> for VersionKeys {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct KeysVisitor;
impl<'de> serde::de::Visitor<'de> for KeysVisitor {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a JSON object mapping version strings to metadata")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut keys = Vec::new();
while let Some((key, _)) = map.next_entry::<String, serde::de::IgnoredAny>()? {
keys.push(key);
}
Ok(keys)
}
}
deserializer.deserialize_map(KeysVisitor).map(VersionKeys)
}
}
impl RealRegistryQuery {
fn http_get(url: &str) -> io::Result<(u16, Vec<u8>)> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.user_agent(concat!("shipshape/", env!("CARGO_PKG_VERSION")))
.http_status_as_error(false)
.max_redirects(0)
.build()
.new_agent();
let mut resp = agent
.get(url)
.call()
.map_err(|e| io::Error::other(format!("HTTP GET {url} failed: {e}")))?;
let status = resp.status().as_u16();
let body = resp
.body_mut()
.with_config()
.limit(Self::MAX_BODY_BYTES)
.read_to_vec()
.map_err(|e| io::Error::other(format!("reading HTTP body from {url} failed: {e}")))?;
Ok((status, body))
}
const MAX_BODY_BYTES: u64 = 10 * 1024 * 1024;
const MAX_NPM_NAME_LEN: usize = 214;
fn validate_npm_package(package: &str) -> io::Result<()> {
let invalid = |msg: String| Err(io::Error::new(io::ErrorKind::InvalidInput, msg));
if package.is_empty() {
return invalid("refusing to query npm for an empty package name".to_string());
}
if package.len() > Self::MAX_NPM_NAME_LEN {
return invalid(format!(
"refusing to query npm for an over-long package name ({} > {} bytes)",
package.len(),
Self::MAX_NPM_NAME_LEN
));
}
if package.starts_with('-') {
return invalid(format!(
"refusing to query npm for a package name that looks like a flag: {package:?}"
));
}
let scoped = package.starts_with('@');
let slashes = package.bytes().filter(|&b| b == b'/').count();
let components: Vec<&str> = if scoped {
let rest = &package[1..];
match rest.split_once('/') {
Some((scope, name)) if !scope.is_empty() && !name.is_empty() && slashes == 1 => {
vec![scope, name]
}
_ => {
return invalid(format!(
"refusing to query npm for a malformed scoped package name: {package:?}"
));
}
}
} else if slashes != 0 {
return invalid(format!(
"refusing to query npm for an unscoped package name containing '/': {package:?}"
));
} else {
vec![package]
};
for component in components {
if component.starts_with('.') || component.starts_with('_') {
return invalid(format!(
"refusing to query npm for a package name whose component starts with '.' or '_': {package:?}"
));
}
}
for (i, &b) in package.as_bytes().iter().enumerate() {
let ok = b.is_ascii_alphanumeric()
|| matches!(b, b'-' | b'_' | b'.')
|| (b == b'@' && i == 0)
|| (b == b'/' && scoped);
if !ok {
return invalid(format!(
"refusing to query npm for a suspicious package name: {package:?}"
));
}
}
Ok(())
}
fn npm_registry_url(package: &str) -> String {
format!("https://registry.npmjs.org/{}", package.replace('/', "%2F"))
}
fn parse_npm_versions(body: &[u8], expected_name: &str) -> io::Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct Packument {
name: String,
versions: VersionKeys,
}
let pack: Packument = serde_json::from_slice(body).map_err(|e| {
io::Error::other(format!("npm registry body was not a valid packument: {e}"))
})?;
if !pack.name.eq_ignore_ascii_case(expected_name) {
return Err(io::Error::other(format!(
"npm registry body was for package {:?}, not the requested {expected_name:?}",
pack.name
)));
}
Ok(pack.versions.0)
}
fn classify_npm_response(
status: u16,
body: &[u8],
expected_name: &str,
) -> io::Result<Vec<String>> {
match status {
200 => {
let versions = Self::parse_npm_versions(body, expected_name)?;
if versions.is_empty() {
return Err(io::Error::other(
"npm registry returned HTTP 200 with no versions; \
treating as unknown rather than 'not published'",
));
}
Ok(versions)
}
404 => Ok(Vec::new()),
other => Err(io::Error::other(format!(
"npm registry returned an unexpected HTTP status {other}"
))),
}
}
fn npm_versions(package: &str) -> io::Result<Vec<String>> {
Self::validate_npm_package(package)?;
let url = Self::npm_registry_url(package);
let (status, body) = Self::http_get(&url)?;
Self::classify_npm_response(status, &body, package)
}
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 classify_sparse_response(
status: u16,
body: &[u8],
expected_name: &str,
) -> io::Result<Vec<String>> {
match status {
200 => {
let body = std::str::from_utf8(body).map_err(|e| {
io::Error::other(format!(
"crates.io sparse index returned a non-UTF-8 body: {e}"
))
})?;
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}"
))),
}
}
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 (status, body) = Self::http_get(&url)?;
Self::classify_sparse_response(status, &body, crate_name)
}
fn crates_io_checksum(crate_name: &str, version: &str) -> io::Result<String> {
Self::validate_crate_name(crate_name)?;
let url = format!(
"https://index.crates.io/{}",
Self::sparse_index_path(crate_name)
);
let (status, body) = Self::http_get(&url)?;
Self::classify_sparse_checksum(status, &body, crate_name, version)
}
fn classify_sparse_checksum(
status: u16,
body: &[u8],
expected_name: &str,
version: &str,
) -> io::Result<String> {
match status {
200 => {
let body = std::str::from_utf8(body).map_err(|e| {
io::Error::other(format!(
"crates.io sparse index returned a non-UTF-8 body: {e}"
))
})?;
Self::parse_sparse_checksum(body, expected_name, version)
}
other => Err(io::Error::other(format!(
"crates.io sparse index returned HTTP {other} while looking up the checksum of \
{expected_name}@{version}; treating as unknown rather than a usable digest"
))),
}
}
fn parse_sparse_checksum(body: &str, expected_name: &str, version: &str) -> io::Result<String> {
#[derive(serde::Deserialize)]
struct SparseCksumEntry {
name: String,
vers: String,
cksum: String,
}
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry: SparseCksumEntry = 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
)));
}
if entry.vers == version {
let cksum = entry.cksum.to_ascii_lowercase();
if cksum.len() != 64 || !cksum.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(io::Error::other(format!(
"crates.io sparse index recorded a malformed checksum for \
{expected_name}@{version}: {:?}",
entry.cksum
)));
}
return Ok(cksum);
}
}
Err(io::Error::other(format!(
"crates.io sparse index carries no record for {expected_name}@{version}, so its \
checksum could not be read"
)))
}
}
impl RegistryQuery for RealRegistryQuery {
fn http_get(&self, url: &str) -> io::Result<(u16, Vec<u8>)> {
RealRegistryQuery::http_get(url)
}
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"
))),
}
}
fn published_checksum(
&self,
ecosystem: &str,
package: &str,
version: &str,
) -> io::Result<String> {
match ecosystem {
"rust" => Self::crates_io_checksum(package, version),
other => Err(io::Error::other(format!(
"no registry checksum 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 is_ancestor(&self, ancestor: &str, descendant: &str) -> io::Result<bool> {
let out = self.run(
"git",
&["merge-base", "--is-ancestor", ancestor, descendant],
)?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Self::check(out, "git merge-base --is-ancestor").map(|_| false),
}
}
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 default_branch(&self) -> io::Result<String> {
let remote = Self::check(
self.run("git", &["ls-remote", "--symref", "origin", "HEAD"])?,
"resolve origin default branch",
)?;
let output = String::from_utf8_lossy(&remote.stdout);
output
.lines()
.find_map(|line| {
let (reference, _) = line.strip_prefix("ref: ")?.split_once('\t')?;
reference.strip_prefix("refs/heads/").map(str::to_string)
})
.ok_or_else(|| io::Error::other("origin did not advertise a symbolic default branch; configure the repository's default branch (or run `git remote set-head origin -a` when the remote is already configured), then run `shipshape release resume <run_id>`"))
}
fn advance_branch(&self, branch: &str, commit: &str) -> io::Result<()> {
let branch_ref = format!("refs/heads/{branch}");
Self::check(
self.run("git", &["check-ref-format", &branch_ref])?,
"validate selected default branch",
)?;
Self::check(
self.run(
"git",
&["rev-parse", "--verify", &format!("{commit}^{{commit}}")],
)?,
"resolve release commit",
)?;
Self::check(
self.run("git", &["fetch", "--no-tags", "origin", &branch_ref])?,
"fetch selected origin default branch",
)?;
let fetched = Self::check(
self.run("git", &["rev-parse", "--verify", "FETCH_HEAD^{commit}"])?,
"resolve fetched default branch",
)?;
let remote_commit = String::from_utf8_lossy(&fetched.stdout).trim().to_string();
if self.is_ancestor(commit, &remote_commit)? {
return Ok(());
}
if !self.is_ancestor(&remote_commit, commit)? {
return Err(io::Error::other(format!(
"origin/{branch} at {remote_commit} has diverged from release commit {commit}; create and push a merge commit that contains {commit} to origin/{branch}, then run `shipshape release resume <run_id>` (or abandon and re-plan)"
)));
}
let refspec = format!("{commit}:{branch_ref}");
Self::check(
self.run("git", &["push", "--porcelain", "origin", &refspec])?,
&format!("fast-forward origin/{branch} (never forced); the remote rejected the update (check branch protection, push permission, network, or concurrent divergence), then run `shipshape release resume <run_id>`"),
)?;
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;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StaleLockOutcome {
Broken { pid: u32 },
NotBroken { reason: String },
}
#[derive(serde::Deserialize)]
struct LockHolder {
pid: u32,
hostname: String,
#[allow(dead_code)]
started_unix: u64,
}
pub fn current_hostname() -> String {
for (program, args) in [("hostname", &[][..]), ("uname", &["-n"][..])] {
if let Ok(output) = Command::new(program)
.args(args)
.stdin(Stdio::null())
.output()
{
if output.status.success() {
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !hostname.is_empty() {
return hostname;
}
}
}
}
String::new()
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> io::Result<bool> {
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
let pid = i32::try_from(pid)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "lock pid exceeds i32"))?;
if unsafe { kill(pid, 0) } == 0 {
return Ok(true);
}
match io::Error::last_os_error().raw_os_error() {
Some(3) => Ok(false), Some(1) => Ok(true), _ => Err(io::Error::last_os_error()),
}
}
#[cfg(not(unix))]
fn process_is_alive(_pid: u32) -> io::Result<bool> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"kill -0 liveness probes are unsupported on this platform",
))
}
impl RealJournalStore {
pub fn break_stale_lock(lock_path: &Path) -> io::Result<StaleLockOutcome> {
let bytes = match std::fs::read(lock_path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(StaleLockOutcome::NotBroken {
reason: "the lock disappeared before it could be inspected".to_string(),
});
}
Err(error) => return Err(error),
};
let holder: LockHolder = match serde_json::from_slice(&bytes) {
Ok(holder) => holder,
Err(_) => {
return Ok(StaleLockOutcome::NotBroken {
reason: "the lock has no readable holder identity (it is legacy or malformed)"
.to_string(),
});
}
};
let hostname = current_hostname();
if hostname.is_empty() {
return Ok(StaleLockOutcome::NotBroken {
reason: "this host's name could not be determined".to_string(),
});
}
if holder.hostname.is_empty() {
return Ok(StaleLockOutcome::NotBroken {
reason: "the recorded holder has no hostname".to_string(),
});
}
if holder.hostname != hostname {
return Ok(StaleLockOutcome::NotBroken {
reason: format!(
"the recorded holder is on host '{}' rather than this host '{}'",
holder.hostname, hostname
),
});
}
match process_is_alive(holder.pid) {
Ok(true) => Ok(StaleLockOutcome::NotBroken {
reason: format!("holder pid {} is still alive", holder.pid),
}),
Ok(false) => {
std::fs::remove_file(lock_path)?;
Ok(StaleLockOutcome::Broken { pid: holder.pid })
}
Err(error) => Ok(StaleLockOutcome::NotBroken {
reason: format!("could not probe holder pid {}: {error}", holder.pid),
}),
}
}
}
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 holder = serde_json::json!({
"pid": std::process::id(),
"hostname": current_hostname(),
"started_unix": SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs()),
});
serde_json::to_writer(&mut f, &holder).map_err(io::Error::other)?;
f.write_all(b"\n")?;
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("shipshape-core"),
"sh/ip/shipshape-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_checksum_returns_the_matching_versions_cksum() {
let sha = "a".repeat(64);
let other = "b".repeat(64);
let body = format!(
"{}\n{}\n",
format_args!(r#"{{"name":"tool","vers":"0.9.0","cksum":"{other}","yanked":false}}"#),
format_args!(r#"{{"name":"tool","vers":"1.0.0","cksum":"{sha}","yanked":false}}"#),
);
assert_eq!(
RealRegistryQuery::parse_sparse_checksum(&body, "tool", "1.0.0").unwrap(),
sha
);
}
#[test]
fn parse_sparse_checksum_fails_closed_when_the_version_is_absent() {
let sha = "a".repeat(64);
let body = format!(r#"{{"name":"tool","vers":"1.0.0","cksum":"{sha}","yanked":false}}"#);
assert!(RealRegistryQuery::parse_sparse_checksum(&body, "tool", "2.0.0").is_err());
}
#[test]
fn parse_sparse_checksum_rejects_a_malformed_cksum() {
let body = r#"{"name":"tool","vers":"1.0.0","cksum":"nothex","yanked":false}"#;
assert!(RealRegistryQuery::parse_sparse_checksum(body, "tool", "1.0.0").is_err());
let body = r#"{"name":"tool","vers":"1.0.0","yanked":false}"#;
assert!(RealRegistryQuery::parse_sparse_checksum(body, "tool", "1.0.0").is_err());
}
#[test]
fn parse_sparse_checksum_rejects_a_wrong_crate_body() {
let sha = "a".repeat(64);
let body = format!(r#"{{"name":"other","vers":"1.0.0","cksum":"{sha}","yanked":false}}"#);
assert!(RealRegistryQuery::parse_sparse_checksum(&body, "tool", "1.0.0").is_err());
}
#[test]
fn classify_sparse_checksum_non_200_fails_closed() {
assert!(RealRegistryQuery::classify_sparse_checksum(404, b"", "tool", "1.0.0").is_err());
assert!(RealRegistryQuery::classify_sparse_checksum(503, b"", "tool", "1.0.0").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 classify_sparse_response_200_returns_versions() {
let body = br#"{"name":"tool","vers":"1.0.0","yanked":false}"#;
assert_eq!(
RealRegistryQuery::classify_sparse_response(200, body, "tool").unwrap(),
vec!["1.0.0"]
);
}
#[test]
fn classify_sparse_response_404_is_empty_not_error() {
assert_eq!(
RealRegistryQuery::classify_sparse_response(404, b"not found", "tool").unwrap(),
Vec::<String>::new()
);
}
#[test]
fn classify_sparse_response_empty_200_fails_closed() {
assert!(RealRegistryQuery::classify_sparse_response(200, b"", "tool").is_err());
assert!(RealRegistryQuery::classify_sparse_response(200, b" \n ", "tool").is_err());
}
#[test]
fn classify_sparse_response_non_utf8_200_fails_closed() {
assert!(RealRegistryQuery::classify_sparse_response(200, &[0xff, 0xfe], "tool").is_err());
}
#[test]
fn classify_sparse_response_unexpected_status_fails_closed() {
assert!(RealRegistryQuery::classify_sparse_response(503, b"", "tool").is_err());
assert!(RealRegistryQuery::classify_sparse_response(410, b"", "tool").is_err());
assert!(RealRegistryQuery::classify_sparse_response(301, b"", "tool").is_err());
}
#[test]
fn classify_npm_response_200_returns_versions() {
let body = br#"{"name":"tool","versions":{"1.0.0":{},"1.1.0":{}}}"#;
let mut versions = RealRegistryQuery::classify_npm_response(200, body, "tool").unwrap();
versions.sort();
assert_eq!(versions, vec!["1.0.0", "1.1.0"]);
}
#[test]
fn classify_npm_response_404_is_empty_not_error() {
let body = br#"{"error":"Not found"}"#;
assert_eq!(
RealRegistryQuery::classify_npm_response(404, body, "tool").unwrap(),
Vec::<String>::new()
);
}
#[test]
fn classify_npm_response_empty_and_unexpected_fail_closed() {
let body = br#"{"name":"tool","versions":{}}"#;
assert!(RealRegistryQuery::classify_npm_response(200, body, "tool").is_err());
let body = br#"{"name":"other","versions":{"1.0.0":{}}}"#;
assert!(RealRegistryQuery::classify_npm_response(200, body, "tool").is_err());
assert!(RealRegistryQuery::classify_npm_response(200, b"not json", "tool").is_err());
assert!(RealRegistryQuery::classify_npm_response(503, b"", "tool").is_err());
let body = br#"{"name":"Tool","versions":{"1.0.0":{}}}"#;
assert_eq!(
RealRegistryQuery::classify_npm_response(200, body, "tool").unwrap(),
vec!["1.0.0"]
);
}
#[test]
fn npm_registry_url_encodes_scoped_slash() {
assert_eq!(
RealRegistryQuery::npm_registry_url("left-pad"),
"https://registry.npmjs.org/left-pad"
);
assert_eq!(
RealRegistryQuery::npm_registry_url("@scope/pkg"),
"https://registry.npmjs.org/@scope%2Fpkg"
);
}
#[test]
fn validate_npm_package_rejects_suspicious_input() {
assert!(RealRegistryQuery::validate_npm_package("left-pad").is_ok());
assert!(RealRegistryQuery::validate_npm_package("lodash.merge").is_ok());
assert!(RealRegistryQuery::validate_npm_package("@babel/core").is_ok());
assert!(RealRegistryQuery::validate_npm_package(
&"a".repeat(RealRegistryQuery::MAX_NPM_NAME_LEN)
)
.is_ok());
assert!(RealRegistryQuery::validate_npm_package(
&"a".repeat(RealRegistryQuery::MAX_NPM_NAME_LEN + 1)
)
.is_err());
assert!(RealRegistryQuery::validate_npm_package("").is_err());
assert!(RealRegistryQuery::validate_npm_package("-oops").is_err());
assert!(RealRegistryQuery::validate_npm_package("a b").is_err());
assert!(RealRegistryQuery::validate_npm_package("a/b").is_err()); assert!(RealRegistryQuery::validate_npm_package("@scope/a/b").is_err()); assert!(RealRegistryQuery::validate_npm_package("@scope").is_err()); assert!(RealRegistryQuery::validate_npm_package("@/pkg").is_err()); assert!(RealRegistryQuery::validate_npm_package("pkg@1").is_err()); assert!(RealRegistryQuery::validate_npm_package(".").is_err());
assert!(RealRegistryQuery::validate_npm_package("..").is_err());
assert!(RealRegistryQuery::validate_npm_package(".hidden").is_err());
assert!(RealRegistryQuery::validate_npm_package("_priv").is_err());
assert!(RealRegistryQuery::validate_npm_package("@scope/.").is_err());
assert!(RealRegistryQuery::validate_npm_package("@scope/..").is_err());
assert!(RealRegistryQuery::validate_npm_package("@.scope/pkg").is_err());
assert!(RealRegistryQuery::validate_npm_package("@_scope/pkg").is_err());
assert!(RealRegistryQuery::validate_npm_package("lodash.merge").is_ok());
assert!(RealRegistryQuery::validate_npm_package("read_file").is_ok());
}
#[test]
fn validate_crate_name_rejects_suspicious_input() {
assert!(RealRegistryQuery::validate_crate_name("shipshape-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());
}
fn serve_once(response: &'static [u8]) -> String {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let _ = stream.write_all(response);
let _ = stream.flush();
}
});
format!("http://{addr}/")
}
#[test]
fn http_get_delivers_200_status_and_body() {
let url = serve_once(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello");
let (status, body) = RealRegistryQuery::http_get(&url).unwrap();
assert_eq!(status, 200);
assert_eq!(body, b"hello");
}
#[test]
fn http_get_delivers_404_as_ok_status_not_error() {
let url = serve_once(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
let (status, body) = RealRegistryQuery::http_get(&url).unwrap();
assert_eq!(status, 404);
assert!(body.is_empty());
}
#[test]
fn http_get_delivers_5xx_as_ok_status() {
let url = serve_once(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n");
let (status, _) = RealRegistryQuery::http_get(&url).unwrap();
assert_eq!(status, 503);
}
fn git(repo: &Path, args: &[&str]) -> std::process::Output {
Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git command")
}
fn release_repo() -> (tempfile::TempDir, PathBuf, String) {
let temp = tempfile::tempdir().unwrap();
let origin = temp.path().join("origin.git");
assert!(
git(temp.path(), &["init", "--bare", origin.to_str().unwrap()])
.status
.success()
);
assert!(git(&origin, &["symbolic-ref", "HEAD", "refs/heads/main"])
.status
.success());
let repo = temp.path().join("repo");
assert!(
git(temp.path(), &["init", "-b", "main", repo.to_str().unwrap()])
.status
.success()
);
assert!(git(&repo, &["config", "user.email", "test@example.com"])
.status
.success());
assert!(git(&repo, &["config", "user.name", "Test"])
.status
.success());
std::fs::write(repo.join("version"), "0.1.0\n").unwrap();
assert!(git(&repo, &["add", "."]).status.success());
assert!(git(&repo, &["commit", "-m", "base"]).status.success());
assert!(git(
&repo,
&["remote", "add", "origin", origin.to_str().unwrap()]
)
.status
.success());
assert!(git(&repo, &["push", "-u", "origin", "main"])
.status
.success());
std::fs::write(repo.join("version"), "0.2.0\n").unwrap();
assert!(git(&repo, &["commit", "-am", "release"]).status.success());
let commit = String::from_utf8(git(&repo, &["rev-parse", "HEAD"]).stdout)
.unwrap()
.trim()
.to_string();
(temp, repo, commit)
}
#[test]
fn default_branch_advance_reproduces_and_repairs_the_tag_only_bump_state() {
let (_temp, repo, release_commit) = release_repo();
assert!(git(&repo, &["checkout", "--detach", &release_commit])
.status
.success());
let tagger = RealTagger::new(&repo);
let branch = tagger.default_branch().unwrap();
assert_eq!(branch, "main");
tagger.advance_branch(&branch, &release_commit).unwrap();
tagger
.advance_branch(&branch, &release_commit)
.expect("retry after a lost journal write is idempotent");
let remote =
String::from_utf8(git(&repo, &["ls-remote", "origin", "refs/heads/main"]).stdout)
.unwrap();
assert!(remote.starts_with(&release_commit));
}
#[test]
fn default_branch_advance_refuses_divergence_without_force() {
let (_temp, repo, release_commit) = release_repo();
assert!(git(&repo, &["checkout", "main~1"]).status.success());
std::fs::write(repo.join("other"), "concurrent\n").unwrap();
assert!(git(&repo, &["add", "."]).status.success());
assert!(git(&repo, &["commit", "-m", "concurrent"]).status.success());
assert!(git(&repo, &["push", "origin", "HEAD:refs/heads/main"])
.status
.success());
let error = RealTagger::new(&repo)
.advance_branch("main", &release_commit)
.unwrap_err()
.to_string();
assert!(error.contains("has diverged"), "{error}");
assert!(error.contains("release resume"), "{error}");
}
#[test]
fn http_get_does_not_follow_redirects() {
let url = serve_once(
b"HTTP/1.1 301 Moved Permanently\r\nLocation: http://127.0.0.1:1/x\r\nContent-Length: 0\r\n\r\n",
);
let (status, _) = RealRegistryQuery::http_get(&url).unwrap();
assert_eq!(status, 301);
}
}