use std::path::PathBuf;
use std::time::Duration;
use callisto_model::{
ApplyPermit, CommandOutput, CommandRunner, Ecosystem, PackageId, PublishOutcome,
RegistryClient, RegistryError, Version,
};
use super::publish::parse_retry_after;
pub struct SubprocessRegistryClient<R: CommandRunner> {
runner: R,
cwd: PathBuf,
}
impl<R: CommandRunner> SubprocessRegistryClient<R> {
pub fn new(runner: R, cwd: PathBuf) -> Self {
Self { runner, cwd }
}
fn run(&self, program: &str, args: &[&str]) -> Result<CommandOutput, RegistryError> {
self.runner
.run(program, args, &self.cwd)
.map_err(|e| RegistryError::Other(e.to_string()))
}
fn cargo_publish(&self, package: &PackageId) -> Result<PublishOutcome, RegistryError> {
if package.name().starts_with('-') {
return Err(RegistryError::Other(format!(
"invalid package name `{}`: names may not begin with '-' (possible flag injection)",
package.name()
)));
}
let output = self.run("cargo", &["publish", "-p", package.name(), "--locked"])?;
classify_cargo_output(&output)
}
fn npm_is_published(
&self,
package: &PackageId,
version: &Version,
) -> Result<bool, RegistryError> {
let spec = format!("{}@{}", package.name(), version.render());
let output = self.run("npm", &["view", &spec, "--json"])?;
if output.success() && !output.stdout_trimmed().is_empty() {
return Ok(true);
}
let combined = combined_lower(&output);
if combined.contains("e404")
|| combined.contains("etarget")
|| combined.contains("no matching version")
|| combined.contains("is not in this registry")
{
return Ok(false);
}
if let Some(err) = detect_rate_limit(&combined) {
return Err(err);
}
if let Some(err) = detect_auth_failure(&combined, &output.stderr) {
return Err(err);
}
Err(RegistryError::Other(format!(
"npm view failed ambiguously (exit {:?}): {}",
output.exit_code,
output.stderr.trim()
)))
}
fn npm_publish(&self, package: &PackageId) -> Result<PublishOutcome, RegistryError> {
if package.name().starts_with('-') {
return Err(RegistryError::Other(format!(
"invalid package name `{}`: names may not begin with '-' (possible flag injection)",
package.name()
)));
}
let output = self.run(
"npm",
&[
"publish",
"--workspace",
package.name(),
"--access",
"public",
],
)?;
classify_npm_publish_output(&output)
}
fn pypi_publish(
&self,
package: &PackageId,
version: &Version,
) -> Result<PublishOutcome, RegistryError> {
if package.name().starts_with('-') {
return Err(RegistryError::Other(format!(
"invalid package name `{}`: names may not begin with '-' (possible flag injection)",
package.name()
)));
}
let normalized = package.name().to_lowercase().replace(['-', '.'], "_");
let pattern = format!("dist/{normalized}-{}*", version.render());
let output = self.run("twine", &["upload", "--skip-existing", &pattern])?;
classify_twine_output(&output)
}
}
impl<R: CommandRunner> RegistryClient for SubprocessRegistryClient<R> {
fn is_published(&self, package: &PackageId, version: &Version) -> Result<bool, RegistryError> {
match package {
PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
..
} => self.npm_is_published(package, version),
PackageId::Prefixed {
ecosystem: Ecosystem::Cargo | Ecosystem::Pypi,
..
} => Ok(false),
other => Err(RegistryError::Other(format!(
"no subprocess is_published check configured for package identity `{}`",
other.display_name()
))),
}
}
fn publish(
&self,
package: &PackageId,
version: &Version,
_permit: &ApplyPermit,
) -> Result<PublishOutcome, RegistryError> {
match package {
PackageId::Prefixed {
ecosystem: Ecosystem::Cargo,
..
} => self.cargo_publish(package),
PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
..
} => self.npm_publish(package),
PackageId::Prefixed {
ecosystem: Ecosystem::Pypi,
..
} => self.pypi_publish(package, version),
other => Err(RegistryError::Other(format!(
"no subprocess publisher configured for package identity `{}`",
other.display_name()
))),
}
}
}
fn combined_lower(output: &CommandOutput) -> String {
format!("{}\n{}", output.stdout, output.stderr).to_lowercase()
}
fn extract_retry_after_duration(text_lower: &str) -> Option<Duration> {
const NEEDLE: &str = "retry after ";
let idx = text_lower.find(NEEDLE)?;
let rest = &text_lower[idx + NEEDLE.len()..];
let token = rest.split_whitespace().next()?;
let digits: String = token.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
parse_retry_after(&digits)
}
fn detect_rate_limit(text_lower: &str) -> Option<RegistryError> {
if text_lower.contains("429")
|| text_lower.contains("too many requests")
|| text_lower.contains("rate limit")
{
let dur = extract_retry_after_duration(text_lower).unwrap_or(Duration::from_secs(60));
Some(RegistryError::RateLimited(dur))
} else {
None
}
}
fn detect_auth_failure(text_lower: &str, raw_stderr: &str) -> Option<RegistryError> {
if text_lower.contains("401")
|| text_lower.contains("403")
|| text_lower.contains("authentication")
|| text_lower.contains("not logged in")
|| text_lower.contains("invalid token")
|| text_lower.contains("forbidden")
{
Some(RegistryError::AuthFailed(raw_stderr.trim().to_string()))
} else {
None
}
}
fn classify_cargo_output(output: &CommandOutput) -> Result<PublishOutcome, RegistryError> {
let combined = combined_lower(output);
if combined.contains("already exists") || combined.contains("already uploaded") {
return Ok(PublishOutcome::AlreadyPublished);
}
if output.success() {
return Ok(PublishOutcome::Published);
}
if let Some(err) = detect_rate_limit(&combined) {
return Err(err);
}
if let Some(err) = detect_auth_failure(&combined, &output.stderr) {
return Err(err);
}
Err(RegistryError::Other(format!(
"cargo publish failed (exit {:?}): {}",
output.exit_code,
output.stderr.trim()
)))
}
fn classify_npm_publish_output(output: &CommandOutput) -> Result<PublishOutcome, RegistryError> {
let combined = combined_lower(output);
if combined.contains("epublishconflict")
|| combined.contains("previously published")
|| combined.contains("cannot publish over")
{
return Ok(PublishOutcome::AlreadyPublished);
}
if output.success() {
return Ok(PublishOutcome::Published);
}
if let Some(err) = detect_rate_limit(&combined) {
return Err(err);
}
if let Some(err) = detect_auth_failure(&combined, &output.stderr) {
return Err(err);
}
Err(RegistryError::Other(format!(
"npm publish failed (exit {:?}): {}",
output.exit_code,
output.stderr.trim()
)))
}
fn classify_twine_output(output: &CommandOutput) -> Result<PublishOutcome, RegistryError> {
let combined = combined_lower(output);
if combined.contains("already exist") {
return Ok(PublishOutcome::AlreadyPublished);
}
if output.success() {
return Ok(PublishOutcome::Published);
}
if let Some(err) = detect_rate_limit(&combined) {
return Err(err);
}
if let Some(err) = detect_auth_failure(&combined, &output.stderr) {
return Err(err);
}
Err(RegistryError::Other(format!(
"twine upload failed (exit {:?}): {}",
output.exit_code,
output.stderr.trim()
)))
}
#[cfg(test)]
mod tests {
fn permit() -> ApplyPermit {
ApplyPermit::force_for_tests()
}
use super::*;
use callisto_model::{CommandError, VersionGrammar};
struct ScriptedRunner(CommandOutput);
impl CommandRunner for ScriptedRunner {
fn run(
&self,
_program: &str,
_args: &[&str],
_cwd: &std::path::Path,
) -> Result<CommandOutput, CommandError> {
Ok(self.0.clone())
}
}
fn output(exit_code: i32, stdout: &str, stderr: &str) -> CommandOutput {
CommandOutput {
exit_code: Some(exit_code),
stdout: stdout.to_string(),
stderr: stderr.to_string(),
}
}
fn cargo_pkg() -> PackageId {
PackageId::Prefixed {
ecosystem: Ecosystem::Cargo,
name: "callisto-model".to_string(),
}
}
fn npm_pkg() -> PackageId {
PackageId::Prefixed {
ecosystem: Ecosystem::Npm,
name: "@callisto/cli".to_string(),
}
}
fn pypi_pkg() -> PackageId {
PackageId::Prefixed {
ecosystem: Ecosystem::Pypi,
name: "callisto-py".to_string(),
}
}
fn v1() -> Version {
Version::parse("1.2.3", VersionGrammar::SemVer).unwrap()
}
fn client(out: CommandOutput) -> SubprocessRegistryClient<ScriptedRunner> {
SubprocessRegistryClient::new(ScriptedRunner(out), PathBuf::from("/workspace"))
}
#[test]
fn cargo_publish_success_is_published() {
let c = client(output(0, "Uploading callisto-model v1.2.3\n", ""));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::Published
);
}
#[test]
fn cargo_publish_already_exists_is_already_published() {
let c = client(output(
101,
"",
"error: crate version `1.2.3` is already uploaded\n",
));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn cargo_publish_already_exists_alt_wording_is_already_published() {
let c = client(output(
101,
"",
"crate version already exists on crates.io\n",
));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn cargo_publish_rate_limited() {
let c = client(output(
101,
"",
"error: failed to publish: 429 Too Many Requests, retry after 30 seconds\n",
));
let err = c.publish(&cargo_pkg(), &v1(), &permit()).unwrap_err();
assert_eq!(err, RegistryError::RateLimited(Duration::from_secs(30)));
}
#[test]
fn cargo_publish_rate_limited_without_parseable_duration_uses_default() {
let c = client(output(101, "", "error: 429 too many requests\n"));
let err = c.publish(&cargo_pkg(), &v1(), &permit()).unwrap_err();
assert_eq!(err, RegistryError::RateLimited(Duration::from_secs(60)));
}
#[test]
fn cargo_publish_auth_failed() {
let c = client(output(
101,
"",
"error: 401 Unauthorized: invalid token for crates.io\n",
));
let err = c.publish(&cargo_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::AuthFailed(_)));
}
#[test]
fn cargo_publish_generic_error() {
let c = client(output(101, "", "error: failed to parse manifest\n"));
let err = c.publish(&cargo_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn cargo_is_published_always_false() {
let c = client(output(0, "", ""));
assert!(!c.is_published(&cargo_pkg(), &v1()).unwrap());
}
#[test]
fn npm_publish_success_is_published() {
let c = client(output(0, "+ @callisto/cli@1.2.3\n", ""));
assert_eq!(
c.publish(&npm_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::Published
);
}
#[test]
fn npm_publish_conflict_is_already_published() {
let c = client(output(
1,
"",
"npm ERR! code EPUBLISHCONFLICT\nnpm ERR! 403 Forbidden - PUT - you cannot publish over the previously published version\n",
));
assert_eq!(
c.publish(&npm_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn npm_publish_rate_limited() {
let c = client(output(
1,
"",
"npm ERR! code E429\nnpm ERR! 429 Too Many Requests - retry after 45 seconds\n",
));
let err = c.publish(&npm_pkg(), &v1(), &permit()).unwrap_err();
assert_eq!(err, RegistryError::RateLimited(Duration::from_secs(45)));
}
#[test]
fn npm_publish_auth_failed() {
let c = client(output(
1,
"",
"npm ERR! code ENEEDAUTH\nnpm ERR! need auth - you must be logged in (not logged in) to publish packages\n",
));
let err = c.publish(&npm_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::AuthFailed(_)));
}
#[test]
fn npm_publish_generic_error() {
let c = client(output(
1,
"",
"npm ERR! code ENOTDIR\nnpm ERR! not a directory\n",
));
let err = c.publish(&npm_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn npm_is_published_true_on_success_with_output() {
let c = client(output(0, "{\"version\":\"1.2.3\"}\n", ""));
assert!(c.is_published(&npm_pkg(), &v1()).unwrap());
}
#[test]
fn npm_is_published_false_on_e404() {
let c = client(output(
1,
"",
"npm ERR! code E404\nnpm ERR! 404 No matching version found for @callisto/cli@1.2.3\n",
));
assert!(!c.is_published(&npm_pkg(), &v1()).unwrap());
}
#[test]
fn npm_is_published_false_on_etarget() {
let c = client(output(
1,
"",
"npm ERR! code ETARGET\nnpm ERR! No matching version found\n",
));
assert!(!c.is_published(&npm_pkg(), &v1()).unwrap());
}
#[test]
fn npm_is_published_ambiguous_failure_propagates_as_error() {
let c = client(output(
1,
"",
"npm ERR! code ECONNRESET\nnpm ERR! socket hang up\n",
));
let err = c.is_published(&npm_pkg(), &v1()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn pypi_publish_success_is_published() {
let c = client(output(
0,
"Uploading callisto_py-1.2.3-py3-none-any.whl\n",
"",
));
assert_eq!(
c.publish(&pypi_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::Published
);
}
#[test]
fn pypi_publish_skip_existing_is_already_published() {
let c = client(output(
0,
"Skipping callisto_py-1.2.3-py3-none-any.whl because it appears to already exist\n",
"",
));
assert_eq!(
c.publish(&pypi_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn pypi_publish_rate_limited() {
let c = client(output(
1,
"",
"HTTPError: 429 Too Many Requests from https://upload.pypi.org/legacy/, retry after 20 seconds\n",
));
let err = c.publish(&pypi_pkg(), &v1(), &permit()).unwrap_err();
assert_eq!(err, RegistryError::RateLimited(Duration::from_secs(20)));
}
#[test]
fn pypi_publish_auth_failed() {
let c = client(output(
1,
"",
"HTTPError: 403 Forbidden from https://upload.pypi.org/legacy/ - Invalid or non-existent authentication information\n",
));
let err = c.publish(&pypi_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::AuthFailed(_)));
}
#[test]
fn pypi_publish_generic_error() {
let c = client(output(1, "", "error: dist/ does not exist\n"));
let err = c.publish(&pypi_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn pypi_is_published_always_false() {
let c = client(output(0, "", ""));
assert!(!c.is_published(&pypi_pkg(), &v1()).unwrap());
}
struct CapturingRunner {
captured_args: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
response: CommandOutput,
}
impl CommandRunner for CapturingRunner {
fn run(
&self,
_program: &str,
args: &[&str],
_cwd: &std::path::Path,
) -> Result<CommandOutput, CommandError> {
*self.captured_args.lock().unwrap() = args.iter().map(|s| s.to_string()).collect();
Ok(self.response.clone())
}
}
#[test]
fn pypi_glob_pattern_uses_underscore_not_hyphen() {
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let runner = CapturingRunner {
captured_args: std::sync::Arc::clone(&captured),
response: output(0, "Uploading my_lib-1.2.3-py3-none-any.whl\n", ""),
};
let pkg = PackageId::Prefixed {
ecosystem: Ecosystem::Pypi,
name: "my-lib".to_string(),
};
let version = Version::parse("1.2.3", VersionGrammar::SemVer).unwrap();
let c = SubprocessRegistryClient::new(runner, PathBuf::from("/workspace"));
c.publish(&pkg, &version, &permit()).unwrap();
let args = captured.lock().unwrap();
assert!(
args.iter().any(|a| a == "dist/my_lib-1.2.3*"),
"expected glob arg 'dist/my_lib-1.2.3*' (PEP 427 underscore) but got: {:?}",
*args
);
}
#[test]
fn cargo_publish_conflicting_already_exists_and_rate_limit_prefers_already_published() {
let c = client(output(
101,
"",
"error: crate version already exists on crates.io\n\
note: internal retry log: saw 429 Too Many Requests, rate limit hit before conflict was detected\n",
));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn npm_publish_conflicting_already_exists_and_rate_limit_prefers_already_published() {
let c = client(output(
1,
"",
"npm ERR! code EPUBLISHCONFLICT\n\
npm ERR! cannot publish over the previously published version\n\
npm ERR! retry log: 429 Too Many Requests during internal retry\n",
));
assert_eq!(
c.publish(&npm_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn pypi_publish_conflicting_already_exists_and_rate_limit_prefers_already_published() {
let c = client(output(
0,
"Skipping callisto_py-1.2.3-py3-none-any.whl because it appears to already exist\n\
retry log: encountered 429 too many requests while retrying upload internally\n",
"",
));
assert_eq!(
c.publish(&pypi_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn cargo_publish_empty_output_nonzero_exit_is_other() {
let c = client(output(1, "", ""));
let err = c.publish(&cargo_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn npm_publish_empty_output_nonzero_exit_is_other() {
let c = client(output(1, "", ""));
let err = c.publish(&npm_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn pypi_publish_empty_output_nonzero_exit_is_other() {
let c = client(output(1, "", ""));
let err = c.publish(&pypi_pkg(), &v1(), &permit()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
#[test]
fn cargo_publish_exit_zero_with_already_exists_text_is_already_published() {
let c = client(output(
0,
"note: crate version already exists, nothing to do\n",
"",
));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn npm_publish_exit_zero_with_already_exists_text_is_already_published() {
let c = client(output(
0,
"notice: previously published, nothing to do\n",
"",
));
assert_eq!(
c.publish(&npm_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn pypi_publish_exit_zero_with_already_exists_text_is_already_published() {
let c = client(output(
0,
"Skipping callisto_py-1.2.3-py3-none-any.whl because it appears to already exist\n",
"",
));
assert_eq!(
c.publish(&pypi_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn cargo_publish_handles_replacement_characters_without_panicking() {
let c = client(output(
101,
"",
"error: crate version \u{FFFD}\u{FFFD} already exists on crates.io\n",
));
assert_eq!(
c.publish(&cargo_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn npm_publish_handles_replacement_characters_without_panicking() {
let c = client(output(
1,
"",
"npm ERR! \u{FFFD} cannot publish over the previously published version\n",
));
assert_eq!(
c.publish(&npm_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn pypi_publish_handles_replacement_characters_without_panicking() {
let c = client(output(
0,
"Skipping callisto_py\u{FFFD}-1.2.3-py3-none-any.whl because it appears to already exist\n",
"",
));
assert_eq!(
c.publish(&pypi_pkg(), &v1(), &permit()).unwrap(),
PublishOutcome::AlreadyPublished
);
}
#[test]
fn test_publish_client_rejects_flag_like_package_name() {
let flag_pkg = |ecosystem: Ecosystem| PackageId::Prefixed {
ecosystem,
name: "--registry=https://evil.com".to_string(),
};
let c = client(output(0, "", ""));
let err = c
.publish(&flag_pkg(Ecosystem::Cargo), &v1(), &permit())
.unwrap_err();
assert!(
matches!(&err, RegistryError::Other(msg) if msg.contains("invalid package name")),
"cargo: expected invalid-package-name error, got: {err:?}"
);
let c = client(output(0, "", ""));
let err = c
.publish(&flag_pkg(Ecosystem::Npm), &v1(), &permit())
.unwrap_err();
assert!(
matches!(&err, RegistryError::Other(msg) if msg.contains("invalid package name")),
"npm: expected invalid-package-name error, got: {err:?}"
);
let c = client(output(0, "", ""));
let err = c
.publish(&flag_pkg(Ecosystem::Pypi), &v1(), &permit())
.unwrap_err();
assert!(
matches!(&err, RegistryError::Other(msg) if msg.contains("invalid package name")),
"pypi: expected invalid-package-name error, got: {err:?}"
);
}
#[test]
fn npm_is_published_handles_replacement_characters_without_panicking() {
let c = client(output(
1,
"",
"npm ERR! code ECONNRESET\u{FFFD}\nnpm ERR! socket \u{FFFD} hang up\n",
));
let err = c.is_published(&npm_pkg(), &v1()).unwrap_err();
assert!(matches!(err, RegistryError::Other(_)));
}
}