use std::collections::BTreeSet;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use chrono::{SecondsFormat, Utc};
use clap::{Parser, Subcommand};
use ed25519_dalek::{Signer, SigningKey};
use flate2::write::GzEncoder;
use flate2::{Compression, GzBuilder};
use rand_core::{OsRng, RngCore};
use reqwest::blocking::Client;
use reqwest::blocking::multipart::{Form, Part};
use serde::Serialize;
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use tar::{Builder, EntryType, Header};
use tempfile::tempdir;
const DEFAULT_REGISTRY: &str = "https://registry.regesta.dev";
const DEFAULT_CHANNEL: &str = "latest";
const REGESTA_CONFIG_FILE: &str = "regesta.json";
const SSH_SIGNATURE_NAMESPACE: &str = "regesta";
const DEFAULT_SOURCE_ENTRIES: &[&str] = &[
REGESTA_CONFIG_FILE,
"package.json",
"README.md",
"LICENSE",
"src",
];
#[derive(Parser)]
#[command(name = "regesta", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "Generate Ed25519 publish key files")]
Keygen {
#[arg(default_value = ".")]
output_dir: PathBuf,
#[arg(long)]
domain: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
kid: Option<String>,
},
#[command(about = "Publish a tarball-backed package")]
Publish {
cwd: Option<PathBuf>,
#[arg(long)]
auth_key: Option<PathBuf>,
#[arg(long)]
kid: Option<String>,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long)]
signing_format: Option<String>,
#[arg(long)]
ssh_signing_key: Option<String>,
#[arg(long)]
ssh_signing_program: Option<String>,
},
#[command(about = "Prepare publish payload without uploading")]
Pack { cwd: Option<PathBuf> },
#[command(about = "Verify a published package release")]
Verify {
spec: String,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
},
#[command(about = "Verify the public registry event log")]
VerifyLog {
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long)]
limit: Option<u16>,
#[arg(long)]
max_pages: Option<usize>,
},
#[command(about = "Verify public package state")]
VerifyPackage {
package_id: String,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long)]
limit: Option<u16>,
#[arg(long)]
max_pages: Option<usize>,
},
#[command(about = "Compare two public registry event-log views")]
CompareLogs {
left_registry: String,
right_registry: String,
#[arg(long)]
limit: Option<u16>,
#[arg(long)]
max_pages: Option<usize>,
},
#[command(about = "Mirror public registry facts to a directory")]
Mirror {
output_dir: PathBuf,
#[arg(long, default_value = DEFAULT_REGISTRY)]
registry: String,
#[arg(long)]
limit: Option<u16>,
#[arg(long)]
max_pages: Option<usize>,
},
#[command(about = "Compare two local mirror directories")]
CompareMirrors {
left_dir: PathBuf,
right_dir: PathBuf,
},
}
#[derive(Debug, Clone)]
struct ParsedPackageId {
ecosystem: String,
id: String,
owner_domain: String,
}
#[derive(Debug, Clone)]
struct PackageVersion {
id: String,
version: String,
}
#[derive(Debug)]
struct PreparedPublish {
artifacts: Vec<PublishArtifact>,
config: Value,
id: String,
source: Vec<u8>,
version: String,
}
#[derive(Debug)]
struct PublishArtifact {
bytes: Vec<u8>,
compatibility: Option<Value>,
filename: Option<String>,
format: Option<String>,
media_type: String,
role: String,
}
#[derive(Debug)]
struct SourceArchive {
bytes: Vec<u8>,
}
#[derive(Debug)]
struct PackageJsonDefaults {
description: Option<String>,
exports: Option<Value>,
name: Option<String>,
repository: Option<String>,
version: Option<String>,
}
#[derive(Serialize)]
struct EventLogResult {
#[serde(rename = "checkedEvents")]
checked_events: usize,
#[serde(skip_serializing_if = "Option::is_none", rename = "lastEventId")]
last_event_id: Option<String>,
ok: bool,
packages: usize,
problems: Vec<String>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Keygen {
output_dir,
domain,
force,
kid,
} => print_json(&keygen(&output_dir, domain, force, kid)?),
Commands::Publish {
cwd,
auth_key,
kid,
registry,
signing_format,
ssh_signing_key,
ssh_signing_program,
} => {
let project_dir = cwd.unwrap_or(std::env::current_dir()?);
let response = publish(PublishOptions {
auth_key,
kid,
project_dir,
registry,
signing_format,
ssh_signing_key,
ssh_signing_program,
})?;
print_json(&response);
}
Commands::Pack { cwd } => {
let project_dir = cwd.unwrap_or(std::env::current_dir()?);
let prepared = prepare_npm_publish(&project_dir)?;
print_json(&json!({
"config": prepared.config,
"artifactBytes": prepared.artifacts.iter().map(|artifact| {
json!({
"bytes": artifact.bytes.len(),
"role": artifact.role,
})
}).collect::<Vec<_>>(),
"sourceBytes": prepared.source.len(),
}));
}
Commands::Verify { spec, registry } => {
let parsed = parse_package_version(&spec)?;
let value = fetch_json(&release_verification_url(
®istry,
&parsed.id,
&parsed.version,
)?)?;
let ok = json_bool(&value, "ok").unwrap_or(false);
print_json(&value);
if !ok {
std::process::exit(1);
}
}
Commands::VerifyLog {
registry,
limit,
max_pages,
} => {
let result = verify_log(®istry, limit, max_pages)?;
let ok = result.ok;
print_json(&result);
if !ok {
std::process::exit(1);
}
}
Commands::VerifyPackage {
package_id,
registry,
limit,
max_pages,
} => {
let parsed = parse_package_id(&package_id)?;
let log = verify_package_log(®istry, &parsed.id, limit, max_pages)?;
let state_url = package_url(®istry, &parsed.id)?;
let state = fetch_json(&state_url)?;
let ok = log.ok;
print_json(&json!({
"checkedEvents": log.checked_events,
"lastEventId": log.last_event_id,
"ok": ok,
"problems": log.problems,
"state": state,
}));
if !ok {
std::process::exit(1);
}
}
Commands::CompareLogs {
left_registry,
right_registry,
limit,
max_pages,
} => {
let left = fetch_event_log(&left_registry, limit, max_pages)?;
let right = fetch_event_log(&right_registry, limit, max_pages)?;
let left_events = left.events;
let right_events = right.events;
let checked_events = left_events.len().min(right_events.len());
let mut problems = Vec::new();
if left_events.len() != right_events.len() {
problems.push("Event logs have different lengths".to_string());
}
for (index, (left_event, right_event)) in
left_events.iter().zip(right_events.iter()).enumerate()
{
if canonical_json(left_event)? != canonical_json(right_event)? {
problems.push(format!("Event logs differ at index {index}"));
break;
}
}
let ok = problems.is_empty();
print_json(&json!({
"checkedEvents": checked_events,
"left": {
"checkedEvents": left_events.len(),
"lastEventId": left.last_event_id,
"packages": packages_from_events(&left_events).len(),
"registry": left_registry,
},
"ok": ok,
"problems": problems,
"right": {
"checkedEvents": right_events.len(),
"lastEventId": right.last_event_id,
"packages": packages_from_events(&right_events).len(),
"registry": right_registry,
},
}));
if !ok {
std::process::exit(1);
}
}
Commands::Mirror {
output_dir,
registry,
limit,
max_pages,
} => {
let result = mirror_registry(®istry, &output_dir, limit, max_pages)?;
let ok = json_bool(&result, "ok").unwrap_or(false);
print_json(&result);
if !ok {
std::process::exit(1);
}
}
Commands::CompareMirrors {
left_dir,
right_dir,
} => {
let left = read_json_file(&left_dir.join("inventory.json"))?;
let right = read_json_file(&right_dir.join("inventory.json"))?;
let ok = canonical_json(&left)? == canonical_json(&right)?;
print_json(&json!({
"checkedEvents": array_len_at(&left, "events").min(array_len_at(&right, "events")),
"checkedObjects": array_len_at(&left, "objects").min(array_len_at(&right, "objects")),
"checkedReleases": array_len_at(&left, "releases").min(array_len_at(&right, "releases")),
"left": left,
"ok": ok,
"problems": if ok { Vec::<String>::new() } else { vec!["Mirror inventories differ".to_string()] },
"right": right,
}));
if !ok {
std::process::exit(1);
}
}
}
Ok(())
}
struct PublishOptions {
auth_key: Option<PathBuf>,
kid: Option<String>,
project_dir: PathBuf,
registry: String,
signing_format: Option<String>,
ssh_signing_key: Option<String>,
ssh_signing_program: Option<String>,
}
fn keygen(
output_dir: &Path,
domain: Option<String>,
force: bool,
kid: Option<String>,
) -> Result<Value> {
fs::create_dir_all(output_dir)
.with_context(|| format!("failed to create {}", output_dir.display()))?;
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let kid = kid.unwrap_or_else(random_ed25519_key_id);
let private_key_jwk = json!({
"crv": "Ed25519",
"d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()),
"kty": "OKP",
"x": URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()),
});
let private_key_file = json!({
"kid": kid,
"privateKeyJwk": private_key_jwk,
});
let public_key_file = json!({
"alg": "EdDSA",
"kid": kid,
"publicKeyJwk": {
"crv": "Ed25519",
"kty": "OKP",
"x": URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()),
},
"use": "regesta-write",
});
let private_key_path = output_dir.join("private-key.json");
let public_key_path = output_dir.join("public-key.json");
write_json_file(&private_key_path, &private_key_file, force, true)?;
write_json_file(&public_key_path, &public_key_file, force, false)?;
let mut result = Map::new();
result.insert(
"privateKey".to_string(),
Value::String(private_key_path.to_string_lossy().into_owned()),
);
result.insert(
"publicKey".to_string(),
Value::String(public_key_path.to_string_lossy().into_owned()),
);
if let Some(domain) = domain {
let domain_binding = json!({
"domain": domain,
"keys": [public_key_file],
"object": "regesta.domain-binding",
});
let domain_binding_path = output_dir.join("domain-binding.json");
write_json_file(&domain_binding_path, &domain_binding, force, false)?;
result.insert(
"domainBinding".to_string(),
Value::String(domain_binding_path.to_string_lossy().into_owned()),
);
}
Ok(Value::Object(result))
}
fn publish(options: PublishOptions) -> Result<Value> {
let registry = normalize_registry_url(&options.registry);
let prepared = prepare_npm_publish(&options.project_dir)?;
let artifact_descriptors = release_publish_artifact_descriptors(&prepared.artifacts);
let intent = create_release_publish_intent(
&prepared.id,
&prepared.version,
sha256_digest(canonical_json(&prepared.config)?.as_bytes()),
sha256_digest(&prepared.source),
prepared
.artifacts
.iter()
.map(|artifact| sha256_digest(&artifact.bytes))
.collect(),
release_publish_artifact_descriptor_digest(&artifact_descriptors)?,
)?;
let authorization = create_authorization(&intent, &options)?;
let artifact_parts = prepared
.artifacts
.iter()
.enumerate()
.map(|(index, artifact)| {
let mut item = Map::new();
item.insert(
"mediaType".to_string(),
Value::String(artifact.media_type.clone()),
);
item.insert(
"part".to_string(),
Value::String(format!("artifact.{index}")),
);
item.insert("role".to_string(), Value::String(artifact.role.clone()));
if let Some(filename) = &artifact.filename {
item.insert("filename".to_string(), Value::String(filename.clone()));
}
if let Some(format) = &artifact.format {
item.insert("format".to_string(), Value::String(format.clone()));
}
if let Some(compatibility) = &artifact.compatibility {
item.insert("compatibility".to_string(), compatibility.clone());
}
Value::Object(item)
})
.collect::<Vec<_>>();
let mut form = Form::new()
.text("config", serde_json::to_string(&prepared.config)?)
.text("authorization", serde_json::to_string(&authorization)?)
.text("artifacts", serde_json::to_string(&artifact_parts)?)
.part(
"source",
Part::bytes(prepared.source)
.file_name("source.tgz")
.mime_str("application/vnd.regesta.source-archive+tgz")?,
);
for (index, artifact) in prepared.artifacts.into_iter().enumerate() {
let filename = artifact
.filename
.clone()
.unwrap_or_else(|| format!("artifact-{index}"));
form = form.part(
format!("artifact.{index}"),
Part::bytes(artifact.bytes)
.file_name(filename)
.mime_str(&artifact.media_type)?,
);
}
let response = Client::new()
.post(format!("{registry}/releases"))
.multipart(form)
.send()
.context("publish request failed")?;
let status = response.status();
let text = response.text().context("failed to read publish response")?;
let value = parse_json_text(&text).unwrap_or_else(|_| json!({ "text": text }));
if !status.is_success() {
bail!("{}", serde_json::to_string_pretty(&value)?);
}
Ok(value)
}
fn prepare_npm_publish(project_dir: &Path) -> Result<PreparedPublish> {
let config = read_npm_regesta_config(project_dir)?;
let id = string_field(&config, "id")?.to_string();
let version = string_field(&config, "version")?.to_string();
let source_archive = create_source_archive(project_dir, &config)?;
let tarball = create_npm_package_tarball(project_dir)?;
Ok(PreparedPublish {
artifacts: vec![PublishArtifact {
bytes: tarball.0,
compatibility: None,
filename: tarball.1,
format: Some("npm-tarball".to_string()),
media_type: "application/gzip".to_string(),
role: "install".to_string(),
}],
config,
id,
source: source_archive.bytes,
version,
})
}
fn read_npm_regesta_config(project_dir: &Path) -> Result<Value> {
let raw = fs::read_to_string(project_dir.join(REGESTA_CONFIG_FILE))
.with_context(|| format!("failed to read {REGESTA_CONFIG_FILE}"))?;
let config_input: Value = json5::from_str(&raw).context("failed to parse regesta.json")?;
let package_json = read_npm_package_json_defaults(project_dir)?;
let normalized_input =
normalize_npm_regesta_config_input(config_input, package_json.name.as_deref())?;
let config = normalize_regesta_config(normalized_input, package_json)?;
let parsed = parse_package_id(string_field(&config, "id")?)?;
if parsed.ecosystem != "npm" {
bail!("npm publish config must use npm ecosystem: {}", parsed.id);
}
Ok(config)
}
fn read_npm_package_json_defaults(project_dir: &Path) -> Result<PackageJsonDefaults> {
let path = project_dir.join("package.json");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(PackageJsonDefaults {
description: None,
exports: None,
name: None,
repository: None,
version: None,
});
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let value: Value = serde_json::from_str(&text).context("failed to parse package.json")?;
let Some(record) = value.as_object() else {
return Ok(PackageJsonDefaults {
description: None,
exports: None,
name: None,
repository: None,
version: None,
});
};
Ok(PackageJsonDefaults {
description: record
.get("description")
.and_then(Value::as_str)
.map(str::to_string),
exports: record.get("exports").cloned().filter(is_package_export),
name: record
.get("name")
.and_then(Value::as_str)
.map(str::to_string),
repository: normalize_repository(record.get("repository")),
version: record
.get("version")
.and_then(Value::as_str)
.map(str::to_string),
})
}
fn normalize_npm_regesta_config_input(
value: Value,
package_json_name: Option<&str>,
) -> Result<Value> {
let Some(record) = value.as_object() else {
return Ok(value);
};
let mut output = record.clone();
match output.get("id") {
Some(Value::String(id)) if !id.contains(':') => {
output.insert(
"id".to_string(),
Value::String(npm_package_id_from_name(id)?),
);
}
None => {
if let Some(name) = package_json_name {
output.insert(
"id".to_string(),
Value::String(npm_package_id_from_name(name)?),
);
}
}
_ => {}
}
Ok(Value::Object(output))
}
fn normalize_regesta_config(value: Value, defaults: PackageJsonDefaults) -> Result<Value> {
let record = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("regesta.json must be an object"))?;
if record.contains_key("$schema") || record.contains_key("schema") {
bail!("regesta.json schema fields are not supported");
}
if record.contains_key("compatibility") {
bail!(
"regesta.json compatibility is not supported; attach compatibility to publish artifacts"
);
}
if record.contains_key("dependencies") {
bail!("regesta.json dependencies are not supported; use ecosystem-native manifests");
}
assert_known_fields(
record,
&[
"description",
"exports",
"family",
"id",
"languages",
"provenance",
"repository",
"source",
"version",
],
"regesta.json",
)?;
let id_value = record
.get("id")
.cloned()
.or_else(|| {
defaults
.name
.as_ref()
.and_then(|name| npm_package_id_from_name(name).ok())
.map(Value::String)
})
.ok_or_else(|| anyhow::anyhow!("regesta.json id must be a package id string"))?;
let id = parse_package_id(
id_value
.as_str()
.ok_or_else(|| anyhow::anyhow!("regesta.json id must be a package id string"))?,
)?
.id;
let version = record
.get("version")
.and_then(Value::as_str)
.or(defaults.version.as_deref())
.ok_or_else(|| anyhow::anyhow!("regesta.json version must be a non-empty string"))?;
assert_non_empty_no_control(version, "regesta.json version")?;
let mut config = Map::new();
config.insert("id".to_string(), Value::String(id));
config.insert(
"provenance".to_string(),
normalize_provenance(record.get("provenance"))?,
);
config.insert("source".to_string(), normalize_source(record)?);
config.insert("version".to_string(), Value::String(version.to_string()));
let description = record
.get("description")
.and_then(Value::as_str)
.or(defaults.description.as_deref());
if let Some(description) = description {
config.insert(
"description".to_string(),
Value::String(description.to_string()),
);
}
let repository = record
.get("repository")
.and_then(Value::as_str)
.or(defaults.repository.as_deref());
if let Some(repository) = repository {
config.insert(
"repository".to_string(),
Value::String(repository.to_string()),
);
}
if let Some(languages) = record.get("languages") {
config.insert(
"languages".to_string(),
Value::Array(normalize_string_array(languages, "regesta.json languages")?),
);
}
let exports_value = record.get("exports").cloned().or(defaults.exports);
if let Some(exports) = exports_value {
if !is_package_export(&exports) {
bail!("package exports must be JSON string, null, array, or object values");
}
config.insert("exports".to_string(), exports);
}
if let Some(family) = record.get("family") {
let family = family
.as_str()
.ok_or_else(|| anyhow::anyhow!("regesta.json family must be a string"))?;
config.insert("family".to_string(), Value::String(family.to_string()));
}
Ok(Value::Object(config))
}
fn normalize_provenance(value: Option<&Value>) -> Result<Value> {
let Some(value) = value else {
return Ok(json!({ "level": "source-attached" }));
};
let record = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("regesta.json provenance must be an object"))?;
assert_known_fields(record, &["level"], "regesta.json provenance")?;
let level = record
.get("level")
.and_then(Value::as_str)
.unwrap_or("source-attached");
if level != "source-attached" {
bail!("regesta.json provenance.level must be source-attached");
}
Ok(json!({ "level": level }))
}
fn normalize_source(record: &Map<String, Value>) -> Result<Value> {
let source = record
.get("source")
.ok_or_else(|| anyhow::anyhow!("regesta.json source is required"))?
.as_object()
.ok_or_else(|| anyhow::anyhow!("regesta.json source must be an object"))?;
assert_known_fields(source, &["exclude", "include"], "regesta.json source")?;
let mut output = Map::new();
if let Some(exclude) = source.get("exclude") {
let items = normalize_source_path_array(exclude, "regesta.json source.exclude")?;
if items
.iter()
.any(|item| trim_trailing_slashes(item) == REGESTA_CONFIG_FILE)
{
bail!("regesta.json source.exclude must not exclude regesta.json");
}
output.insert(
"exclude".to_string(),
Value::Array(items.into_iter().map(Value::String).collect()),
);
}
if let Some(include) = source.get("include") {
let items = normalize_source_path_array(include, "regesta.json source.include")?;
output.insert(
"include".to_string(),
Value::Array(items.into_iter().map(Value::String).collect()),
);
}
Ok(Value::Object(output))
}
fn create_source_archive(project_dir: &Path, config: &Value) -> Result<SourceArchive> {
let source = config
.get("source")
.and_then(Value::as_object)
.ok_or_else(|| anyhow::anyhow!("regesta config source is required"))?;
let include = source.get("include").and_then(Value::as_array);
let exclude = source
.get("exclude")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let requested_entries = if let Some(include) = include {
include
.iter()
.map(|item| {
item.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("regesta source include paths must be a string"))
})
.collect::<Result<Vec<_>>>()?
} else {
DEFAULT_SOURCE_ENTRIES
.iter()
.map(|item| item.to_string())
.collect()
};
let mut entries = Vec::new();
let require_entries = include.is_some();
for entry in requested_entries {
let normalized = normalize_archive_path(&entry, "regesta source include")?;
if is_excluded(&normalized, &exclude) {
continue;
}
let full_path = project_dir.join(&normalized);
if require_entries {
fs::metadata(&full_path)
.with_context(|| format!("missing source archive entry {}", normalized))?;
entries.push(normalized);
} else if full_path.exists() {
entries.push(normalized);
}
}
if !entries
.iter()
.any(|entry| trim_trailing_slashes(entry) == REGESTA_CONFIG_FILE)
{
entries.push(REGESTA_CONFIG_FILE.to_string());
}
entries.sort();
entries.dedup();
let encoder = GzBuilder::new()
.mtime(0)
.write(Vec::new(), Compression::default());
let mut builder = Builder::new(encoder);
for entry in &entries {
append_archive_entry(&mut builder, project_dir, entry, &exclude)?;
}
let encoder: GzEncoder<Vec<u8>> = builder.into_inner()?;
let bytes = encoder.finish()?;
Ok(SourceArchive { bytes })
}
fn append_archive_entry(
builder: &mut Builder<GzEncoder<Vec<u8>>>,
project_dir: &Path,
entry: &str,
exclude: &[String],
) -> Result<()> {
if is_excluded(entry, exclude) {
return Ok(());
}
let full_path = project_dir.join(entry);
let metadata = fs::metadata(&full_path)
.with_context(|| format!("failed to stat source archive entry {entry}"))?;
if metadata.is_dir() {
append_directory(builder, entry)?;
let mut children = fs::read_dir(&full_path)?
.map(|item| item.map(|entry| entry.file_name().to_string_lossy().into_owned()))
.collect::<io::Result<Vec<_>>>()?;
children.sort();
for child in children {
let child_entry = format!("{}/{}", trim_trailing_slashes(entry), child);
append_archive_entry(builder, project_dir, &child_entry, exclude)?;
}
return Ok(());
}
if metadata.is_file() {
let mut file = File::open(&full_path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
builder.append_data(&mut header, entry, bytes.as_slice())?;
}
Ok(())
}
fn append_directory(builder: &mut Builder<GzEncoder<Vec<u8>>>, entry: &str) -> Result<()> {
let mut path = trim_trailing_slashes(entry).to_string();
path.push('/');
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Directory);
header.set_size(0);
header.set_mode(0o755);
header.set_mtime(0);
header.set_cksum();
builder.append_data(&mut header, path, io::empty())?;
Ok(())
}
fn create_npm_package_tarball(project_dir: &Path) -> Result<(Vec<u8>, Option<String>)> {
let output_dir = tempdir()?;
let package_manager = detect_package_manager(project_dir)?;
let mut command = Command::new(&package_manager);
match package_manager.as_str() {
"pnpm" => {
command.args(["pack", "--pack-destination"]);
command.arg(output_dir.path());
}
"npm" => {
command.args(["pack", "--pack-destination"]);
command.arg(output_dir.path());
}
_ => bail!(
"Unsupported package manager for npm tarball generation: {}",
package_manager
),
}
let output = command
.current_dir(project_dir)
.output()
.with_context(|| format!("failed to run {package_manager} pack"))?;
if !output.status.success() {
bail!(
"{} pack failed with exit code {:?}: {}",
package_manager,
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
let tarballs = fs::read_dir(output_dir.path())?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|item| item.to_str()) == Some("tgz"))
.collect::<Vec<_>>();
if tarballs.len() != 1 {
bail!(
"Package manager pack must produce exactly one .tgz file, found {}",
tarballs.len()
);
}
let path = &tarballs[0];
let filename = path
.file_name()
.and_then(|item| item.to_str())
.map(str::to_string);
Ok((fs::read(path)?, filename))
}
fn detect_package_manager(project_dir: &Path) -> Result<String> {
if let Some(package_manager) = find_package_manager_field(project_dir)? {
return Ok(package_manager);
}
if project_dir.join("pnpm-lock.yaml").exists() {
return Ok("pnpm".to_string());
}
if project_dir.join("package-lock.json").exists() {
return Ok("npm".to_string());
}
Ok("npm".to_string())
}
fn find_package_manager_field(project_dir: &Path) -> Result<Option<String>> {
let mut current = project_dir.to_path_buf();
loop {
let package_json_path = current.join("package.json");
match fs::read_to_string(&package_json_path) {
Ok(text) => {
let value: Value = serde_json::from_str(&text)
.with_context(|| format!("failed to parse {}", package_json_path.display()))?;
if let Some(package_manager) = value
.get("packageManager")
.and_then(Value::as_str)
.and_then(|value| value.split('@').next())
{
return Ok(Some(package_manager.to_string()));
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
if !current.pop() {
return Ok(None);
}
}
}
fn create_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
let format = options.signing_format.clone().unwrap_or_else(|| {
if options.auth_key.is_some() {
"ed25519"
} else {
"ssh"
}
.to_string()
});
match format.as_str() {
"ed25519" => create_ed25519_authorization(intent, options),
"ssh" => create_ssh_authorization(intent, options),
_ => bail!("Unsupported signing format: {}", format),
}
}
fn create_ed25519_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
let auth_key = options
.auth_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing --auth-key for Ed25519 signed publish"))?;
let key_file = read_json_file(auth_key)?;
let private_key_jwk = key_file
.get("privateKeyJwk")
.unwrap_or(&key_file)
.as_object()
.ok_or_else(|| anyhow::anyhow!("privateKeyJwk must be an object"))?;
let kid = options
.kid
.clone()
.or_else(|| {
key_file
.get("kid")
.and_then(Value::as_str)
.map(str::to_string)
})
.ok_or_else(|| anyhow::anyhow!("Missing --kid or kid in auth key file"))?;
let d = private_key_jwk
.get("d")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("privateKeyJwk must include x and d"))?;
let x = private_key_jwk
.get("x")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("privateKeyJwk must include x and d"))?;
if private_key_jwk.get("kty").and_then(Value::as_str) != Some("OKP")
|| private_key_jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
{
bail!("privateKeyJwk must be an Ed25519 OKP JWK");
}
let secret = URL_SAFE_NO_PAD
.decode(d)
.context("privateKeyJwk d must be base64url")?;
let secret: [u8; 32] = secret
.try_into()
.map_err(|_| anyhow::anyhow!("privateKeyJwk d must decode to 32 bytes"))?;
let signing_key = SigningKey::from_bytes(&secret);
let public = URL_SAFE_NO_PAD
.decode(x)
.context("privateKeyJwk x must be base64url")?;
if signing_key.verifying_key().to_bytes().as_slice() != public.as_slice() {
bail!("privateKeyJwk public component does not match private key");
}
let signature = signing_key.sign(canonical_json(intent)?.as_bytes());
Ok(json!({
"alg": "EdDSA",
"kid": kid,
"payload": intent,
"signature": URL_SAFE_NO_PAD.encode(signature.to_bytes()),
}))
}
fn create_ssh_authorization(intent: &Value, options: &PublishOptions) -> Result<Value> {
let signing_key = options
.ssh_signing_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing --ssh-signing-key for ssh signed publish"))?;
let public_key = resolve_ssh_public_key(signing_key, &options.project_dir)?;
let kid = options.kid.clone().unwrap_or_else(|| {
ssh_ed25519_public_key_id(&public_key).unwrap_or_else(|_| "ssh-ed25519:invalid".to_string())
});
let signature = sign_with_ssh_program(
options
.ssh_signing_program
.as_deref()
.unwrap_or("ssh-keygen"),
signing_key,
canonical_json(intent)?.as_bytes(),
&options.project_dir,
)?;
Ok(json!({
"alg": "ssh-ed25519",
"kid": kid,
"payload": intent,
"signature": signature,
}))
}
fn sign_with_ssh_program(
program: &str,
signing_key: &str,
payload: &[u8],
cwd: &Path,
) -> Result<String> {
let temp = tempdir()?;
let payload_path = temp.path().join("payload");
let signature_path = temp.path().join("payload.sig");
fs::write(&payload_path, payload)?;
let output = Command::new(program)
.args([
"-Y",
"sign",
"-f",
signing_key,
"-n",
SSH_SIGNATURE_NAMESPACE,
])
.arg(&payload_path)
.current_dir(cwd)
.output()
.with_context(|| format!("failed to run SSH signing program {program}"))?;
if !output.status.success() {
bail!(
"{} -Y sign failed with exit code {:?}: {}",
program,
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
if let Ok(signature) = fs::read_to_string(&signature_path)
&& !signature.trim().is_empty()
{
return Ok(signature);
}
let stdout = String::from_utf8_lossy(&output.stdout);
extract_ssh_signature(&stdout)
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("SSH signing program did not produce a signature"))
}
fn resolve_ssh_public_key(signing_key: &str, cwd: &Path) -> Result<String> {
let trimmed = signing_key.trim();
if trimmed.starts_with("ssh-ed25519 ") {
return normalize_ssh_ed25519_public_key(trimmed);
}
let key_path = resolve_path(cwd, trimmed);
if let Some(public_key) = read_ssh_public_key_file(&key_path)? {
return Ok(public_key);
}
if let Some(public_key) =
read_ssh_public_key_file(&PathBuf::from(format!("{}.pub", key_path.display())))?
{
return Ok(public_key);
}
bail!(
"SSH signing key must be an ssh-ed25519 public key, public key file, or private key path with a .pub file"
);
}
fn read_ssh_public_key_file(path: &Path) -> Result<Option<String>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
for line in text.lines().map(str::trim) {
if line.starts_with("ssh-ed25519 ") {
return Ok(Some(normalize_ssh_ed25519_public_key(line)?));
}
}
Ok(None)
}
fn extract_ssh_signature(value: &str) -> Option<&str> {
let start = value.find("-----BEGIN SSH SIGNATURE-----")?;
let end_marker = "-----END SSH SIGNATURE-----";
let end = value[start..].find(end_marker)? + start + end_marker.len();
Some(&value[start..end])
}
fn release_publish_artifact_descriptors(artifacts: &[PublishArtifact]) -> Vec<Value> {
artifacts
.iter()
.map(|artifact| {
let mut item = Map::new();
if let Some(compatibility) = &artifact.compatibility {
item.insert("compatibility".to_string(), compatibility.clone());
}
item.insert(
"digest".to_string(),
Value::String(sha256_digest(&artifact.bytes)),
);
if let Some(filename) = &artifact.filename {
item.insert("filename".to_string(), Value::String(filename.clone()));
}
if let Some(format) = &artifact.format {
item.insert("format".to_string(), Value::String(format.clone()));
}
item.insert(
"mediaType".to_string(),
Value::String(artifact.media_type.clone()),
);
item.insert("role".to_string(), Value::String(artifact.role.clone()));
Value::Object(item)
})
.collect()
}
fn release_publish_artifact_descriptor_digest(artifacts: &[Value]) -> Result<String> {
if artifacts.is_empty() {
bail!("artifactDescriptors must not be empty");
}
Ok(sha256_digest(
canonical_json(&Value::Array(artifacts.to_vec()))?.as_bytes(),
))
}
fn create_release_publish_intent(
package_id: &str,
version: &str,
config_digest: String,
source_digest: String,
artifact_digests: Vec<String>,
artifact_descriptor_digest: String,
) -> Result<Value> {
let parsed = parse_package_id(package_id)?;
Ok(json!({
"artifactDescriptorDigest": artifact_descriptor_digest,
"artifactDigests": artifact_digests,
"channel": DEFAULT_CHANNEL,
"configDigest": config_digest,
"domain": parsed.owner_domain,
"nonce": random_nonce(),
"object": "regesta.write-intent",
"operation": "release.publish",
"package": parsed.id,
"sourceDigest": source_digest,
"timestamp": canonical_now(),
"version": version,
}))
}
fn verify_log(
registry: &str,
limit: Option<u16>,
max_pages: Option<usize>,
) -> Result<EventLogResult> {
let events = fetch_event_log(registry, limit, max_pages)?;
Ok(EventLogResult {
checked_events: events.events.len(),
last_event_id: events.last_event_id,
ok: events.problems.is_empty(),
packages: packages_from_events(&events.events).len(),
problems: events.problems,
})
}
fn verify_package_log(
registry: &str,
package_id: &str,
limit: Option<u16>,
max_pages: Option<usize>,
) -> Result<EventLogResult> {
let events = fetch_event_log(registry, limit, max_pages)?;
let package_events = events
.events
.into_iter()
.filter(|event| registry_event_package_id(event).as_deref() == Some(package_id))
.collect::<Vec<_>>();
Ok(EventLogResult {
checked_events: package_events.len(),
last_event_id: package_events
.last()
.and_then(|event| event.get("id"))
.and_then(Value::as_str)
.map(str::to_string),
ok: events.problems.is_empty(),
packages: usize::from(!package_events.is_empty()),
problems: events.problems,
})
}
#[derive(Debug)]
struct EventLog {
events: Vec<Value>,
last_event_id: Option<String>,
problems: Vec<String>,
}
fn fetch_event_log(
registry: &str,
limit: Option<u16>,
max_pages: Option<usize>,
) -> Result<EventLog> {
let limit = usize::from(limit.unwrap_or(999));
if limit == 0 || limit > 999 {
bail!("Event log page size must be an integer from 1 to 999");
}
let max_pages = max_pages.unwrap_or(1000);
let mut events = Vec::new();
let mut problems = Vec::new();
let mut after: Option<String> = None;
for _ in 0..max_pages {
let page = fetch_json(&event_log_url(registry, after.as_deref(), limit))?;
let Some(page_events) = page.get("events").and_then(Value::as_array) else {
problems.push("Event log response must include events array".to_string());
break;
};
if page_events.is_empty() {
if page.get("nextAfter").is_some() {
problems.push("Event log empty page must not include nextAfter".to_string());
}
return Ok(EventLog {
events,
last_event_id: after,
problems,
});
}
if page_events.len() > limit {
problems.push("Event log page returned more events than requested".to_string());
break;
}
let last_id = page_events
.last()
.and_then(|event| event.get("id"))
.and_then(Value::as_str)
.map(str::to_string);
if page
.get("nextAfter")
.and_then(Value::as_str)
.map(str::to_string)
!= last_id
{
problems.push("Event log nextAfter must match last event id".to_string());
break;
}
events.extend(page_events.iter().cloned());
after = last_id;
}
if problems.is_empty() {
problems.push("Event log stopped before reaching tail".to_string());
}
Ok(EventLog {
events,
last_event_id: after,
problems,
})
}
fn mirror_registry(
registry: &str,
output_dir: &Path,
limit: Option<u16>,
max_pages: Option<usize>,
) -> Result<Value> {
fs::create_dir_all(output_dir)?;
let event_log = fetch_event_log(registry, limit, max_pages)?;
let mirrored_at = canonical_now();
let event_ids = event_log
.events
.iter()
.filter_map(|event| event.get("id").and_then(Value::as_str).map(str::to_string))
.collect::<Vec<_>>();
let packages = packages_from_events(&event_log.events);
fs::create_dir_all(output_dir.join("events"))?;
for event in &event_log.events {
if let Some(id) = event.get("id").and_then(Value::as_str) {
fs::write(
output_dir
.join("events")
.join(digest_file_name(id, "json")?),
format!("{}\n", canonical_json(event)?),
)?;
}
}
let object_digests = mirror_objects(registry, output_dir, limit, max_pages)?;
let inventory = json!({
"events": event_ids,
"kind": "regesta.local-mirror.inventory",
"lastEventId": event_log.last_event_id,
"mirroredAt": mirrored_at,
"objects": object_digests,
"ok": event_log.problems.is_empty(),
"packages": packages.iter().cloned().collect::<Vec<_>>(),
"problems": event_log.problems,
"registry": normalize_registry_url(registry),
"releases": releases_from_events(&event_log.events),
});
fs::write(
output_dir.join("inventory.json"),
format!("{}\n", canonical_json(&inventory)?),
)?;
Ok(json!({
"events": array_len_at(&inventory, "events"),
"lastEventId": inventory.get("lastEventId").cloned().unwrap_or(Value::Null),
"mirroredAt": mirrored_at,
"objects": array_len_at(&inventory, "objects"),
"ok": json_bool(&inventory, "ok").unwrap_or(false),
"outputDir": output_dir,
"packages": array_len_at(&inventory, "packages"),
"problems": inventory.get("problems").cloned().unwrap_or_else(|| json!([])),
"registry": normalize_registry_url(registry),
"releases": array_len_at(&inventory, "releases"),
}))
}
fn mirror_objects(
registry: &str,
output_dir: &Path,
limit: Option<u16>,
max_pages: Option<usize>,
) -> Result<Vec<String>> {
let limit = usize::from(limit.unwrap_or(999));
let max_pages = max_pages.unwrap_or(1000);
let mut after: Option<String> = None;
let mut digests = Vec::new();
fs::create_dir_all(output_dir.join("objects"))?;
for _ in 0..max_pages {
let page = fetch_json(&object_inventory_url(registry, after.as_deref(), limit))?;
let Some(objects) = page.get("objects").and_then(Value::as_array) else {
break;
};
if objects.is_empty() {
break;
}
for object in objects {
let Some(digest) = object.get("digest").and_then(Value::as_str) else {
continue;
};
let bytes = fetch_bytes(&object_url(registry, digest)?)
.with_context(|| format!("failed to mirror object {digest}"))?;
fs::write(
output_dir
.join("objects")
.join(digest_file_name(digest, "bin")?),
bytes,
)?;
digests.push(digest.to_string());
}
after = page
.get("nextAfter")
.and_then(Value::as_str)
.map(str::to_string);
if after.is_none() {
break;
}
}
digests.sort();
digests.dedup();
Ok(digests)
}
fn fetch_json(url: &str) -> Result<Value> {
let response = Client::new().get(url).send()?;
let status = response.status();
let text = response.text()?;
if !status.is_success() {
bail!("GET {url} failed with status {status}: {text}");
}
parse_json_text(&text).with_context(|| format!("GET {url} did not return JSON"))
}
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let response = Client::new().get(url).send()?;
let status = response.status();
if !status.is_success() {
bail!("GET {url} failed with status {status}");
}
Ok(response.bytes()?.to_vec())
}
fn parse_package_id(value: &str) -> Result<ParsedPackageId> {
let Some((ecosystem, name)) = value.split_once(':') else {
bail!("Invalid package id: {}", value);
};
if ecosystem.is_empty()
|| name.is_empty()
|| !ecosystem
.chars()
.all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == '-')
{
bail!("Invalid package id: {}", value);
}
if name.starts_with('@') {
bail!(
"Package id must not include native package syntax: {}",
value
);
}
if has_control_character(name) {
bail!("Invalid package name: {}", value);
}
let Some((owner_domain, package_name)) = name.split_once('/') else {
bail!("Package id must include an owner domain: {}", value);
};
if !is_canonical_owner_domain(owner_domain) {
bail!("Package id owner must be a domain: {}", value);
}
if package_name.split('/').any(str::is_empty) {
bail!("Package id name must not contain empty segments: {}", value);
}
Ok(ParsedPackageId {
ecosystem: ecosystem.to_string(),
id: format!("{ecosystem}:{name}"),
owner_domain: owner_domain.to_string(),
})
}
fn parse_package_version(value: &str) -> Result<PackageVersion> {
let ecosystem_separator_index = value
.find(':')
.ok_or_else(|| anyhow::anyhow!("Package version must include a package id: {value}"))?;
let separator_index = value
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("Package version must include a version: {value}"))?;
if separator_index <= ecosystem_separator_index {
bail!("Package version must include a version: {}", value);
}
let id = parse_package_id(&value[..separator_index])?.id;
let version = &value[separator_index + 1..];
assert_non_empty_no_control(version, "Package version")?;
Ok(PackageVersion {
id,
version: version.to_string(),
})
}
fn is_canonical_owner_domain(value: &str) -> bool {
value.len() <= 253
&& value.contains('.')
&& value.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.chars()
.all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == '-')
&& label
.chars()
.next()
.is_some_and(|item| item.is_ascii_lowercase() || item.is_ascii_digit())
&& label
.chars()
.last()
.is_some_and(|item| item.is_ascii_lowercase() || item.is_ascii_digit())
})
}
fn npm_package_id_from_name(package_name: &str) -> Result<String> {
let Some(name) = package_name.strip_prefix('@') else {
bail!("npm package name must be domain-scoped: {}", package_name);
};
let segments = name.split('/').collect::<Vec<_>>();
if segments.len() != 2 || segments.iter().any(|segment| segment.is_empty()) {
bail!("npm package name must be domain-scoped: {}", package_name);
}
Ok(parse_package_id(&format!("npm:{name}"))?.id)
}
fn normalize_archive_path(entry: &str, field: &str) -> Result<String> {
assert_source_archive_path(entry, &format!("{field} paths"))?;
Ok(entry.to_string())
}
fn assert_source_archive_path(value: &str, label: &str) -> Result<()> {
if value.is_empty() {
bail!("{label} must be non-empty");
}
if has_control_character(value) {
bail!("{label} must not contain control characters");
}
if value.contains('\\') {
bail!("{label} must use forward slashes");
}
if value.starts_with('/') || looks_like_windows_absolute_path(value) {
bail!("{label} must be relative");
}
let segments = value.split('/').collect::<Vec<_>>();
if value == "." || value.starts_with("./") || value.contains("//") || segments.contains(&".") {
bail!("{label} must be normalized");
}
if segments.contains(&"..") {
bail!("{label} must not contain parent directory segments");
}
Ok(())
}
fn normalize_source_path_array(value: &Value, field: &str) -> Result<Vec<String>> {
let array = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("{field} must be an array"))?;
array
.iter()
.map(|item| {
let path = item
.as_str()
.ok_or_else(|| anyhow::anyhow!("{field} must contain strings"))?;
assert_source_archive_path(path, &format!("{field} paths"))?;
Ok(path.to_string())
})
.collect()
}
fn is_excluded(entry: &str, excluded_entries: &[String]) -> bool {
let normalized_entry = trim_trailing_slashes(entry);
excluded_entries.iter().any(|excluded| {
let excluded = trim_trailing_slashes(excluded);
normalized_entry == excluded || normalized_entry.starts_with(&format!("{excluded}/"))
})
}
fn canonical_json(value: &Value) -> Result<String> {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
Ok(serde_json::to_string(value)?)
}
Value::Array(items) => {
let mut output = String::from("[");
for (index, item) in items.iter().enumerate() {
if index > 0 {
output.push(',');
}
output.push_str(&canonical_json(item)?);
}
output.push(']');
Ok(output)
}
Value::Object(record) => {
let mut keys = record.keys().collect::<Vec<_>>();
keys.sort();
let mut output = String::from("{");
for (index, key) in keys.iter().enumerate() {
if index > 0 {
output.push(',');
}
output.push_str(&serde_json::to_string(key)?);
output.push(':');
output.push_str(&canonical_json(&record[*key])?);
}
output.push('}');
Ok(output)
}
}
}
fn sha256_digest(bytes: impl AsRef<[u8]>) -> String {
let digest = Sha256::digest(bytes.as_ref());
format!("sha256:{}", hex::encode(digest))
}
fn normalize_ssh_ed25519_public_key(public_key: &str) -> Result<String> {
let parsed = parse_ssh_ed25519_public_key(public_key)?;
Ok(format!("ssh-ed25519 {}", STANDARD.encode(parsed.blob)))
}
struct SshEd25519PublicKey {
blob: Vec<u8>,
}
fn ssh_ed25519_public_key_id(public_key: &str) -> Result<String> {
let parsed = parse_ssh_ed25519_public_key(public_key)?;
let digest = Sha256::digest(&parsed.blob);
Ok(format!("ssh-ed25519:{}", URL_SAFE_NO_PAD.encode(digest)))
}
fn parse_ssh_ed25519_public_key(value: &str) -> Result<SshEd25519PublicKey> {
let mut parts = value.split_whitespace();
if parts.next() != Some("ssh-ed25519") {
bail!("publicKey must start with ssh-ed25519");
}
let encoded = parts
.next()
.ok_or_else(|| anyhow::anyhow!("publicKey must include key data"))?;
let blob = STANDARD
.decode(encoded)
.context("invalid SSH public key base64")?;
let mut reader = SshReader::new(&blob);
let algorithm = reader.string_utf8()?;
if algorithm != "ssh-ed25519" {
bail!("publicKey algorithm must be ssh-ed25519");
}
let key = reader.string_bytes()?;
if key.len() != 32 {
bail!("publicKey Ed25519 key must be 32 bytes");
}
if !reader.is_empty() {
bail!("publicKey must not include trailing data");
}
Ok(SshEd25519PublicKey { blob })
}
struct SshReader<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> SshReader<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn string_bytes(&mut self) -> Result<&'a [u8]> {
if self.offset + 4 > self.bytes.len() {
bail!("SSH string is truncated");
}
let length = u32::from_be_bytes(
self.bytes[self.offset..self.offset + 4]
.try_into()
.expect("slice length checked"),
) as usize;
self.offset += 4;
if self.offset + length > self.bytes.len() {
bail!("SSH string is truncated");
}
let value = &self.bytes[self.offset..self.offset + length];
self.offset += length;
Ok(value)
}
fn string_utf8(&mut self) -> Result<String> {
Ok(std::str::from_utf8(self.string_bytes()?)?.to_string())
}
fn is_empty(&self) -> bool {
self.offset == self.bytes.len()
}
}
fn write_json_file(path: &Path, value: &Value, force: bool, private: bool) -> Result<()> {
let mut options = OpenOptions::new();
options.write(true);
if force {
options.create(true).truncate(true);
} else {
options.create_new(true);
}
#[cfg(unix)]
if private {
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("failed to write {}", path.display()))?;
serde_json::to_writer_pretty(&mut file, value)?;
use std::io::Write;
file.write_all(b"\n")?;
Ok(())
}
fn read_json_file(path: &Path) -> Result<Value> {
let text =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
parse_json_text(&text).with_context(|| format!("failed to parse {}", path.display()))
}
fn parse_json_text(text: &str) -> Result<Value> {
Ok(serde_json::from_str(text)?)
}
fn normalize_registry_url(registry: &str) -> String {
registry.trim_end_matches('/').to_string()
}
fn release_verification_url(registry: &str, package_id: &str, version: &str) -> Result<String> {
Ok(format!(
"{}/packages/{}/releases/{}/verification",
normalize_registry_url(registry),
encoded_package_id(package_id)?,
urlencoding::encode(version)
))
}
fn package_url(registry: &str, package_id: &str) -> Result<String> {
Ok(format!(
"{}/packages/{}",
normalize_registry_url(registry),
encoded_package_id(package_id)?
))
}
fn event_log_url(registry: &str, after: Option<&str>, limit: usize) -> String {
let mut url = format!("{}/events?limit={limit}", normalize_registry_url(registry));
if let Some(after) = after {
url.push_str("&after=");
url.push_str(&urlencoding::encode(after));
}
url
}
fn object_inventory_url(registry: &str, after: Option<&str>, limit: usize) -> String {
let mut url = format!("{}/objects?limit={limit}", normalize_registry_url(registry));
if let Some(after) = after {
url.push_str("&after=");
url.push_str(&urlencoding::encode(after));
}
url
}
fn object_url(registry: &str, digest: &str) -> Result<String> {
Ok(format!(
"{}/objects/{}",
normalize_registry_url(registry),
encoded_digest(digest)?
))
}
fn encoded_package_id(package_id: &str) -> Result<String> {
Ok(urlencoding::encode(&parse_package_id(package_id)?.id).into_owned())
}
fn encoded_digest(digest: &str) -> Result<String> {
let Some((algorithm, hex)) = digest.split_once(':') else {
bail!("Invalid digest: {}", digest);
};
Ok(format!("{algorithm}/{hex}"))
}
fn digest_file_name(digest: &str, extension: &str) -> Result<String> {
let Some((algorithm, hex)) = digest.split_once(':') else {
bail!("Invalid digest: {}", digest);
};
Ok(format!("{algorithm}-{hex}.{extension}"))
}
fn print_json<T: Serialize>(value: &T) {
println!(
"{}",
serde_json::to_string_pretty(value).expect("JSON output must serialize")
);
}
fn packages_from_events(events: &[Value]) -> BTreeSet<String> {
events
.iter()
.filter_map(registry_event_package_id)
.collect()
}
fn releases_from_events(events: &[Value]) -> Vec<Value> {
events
.iter()
.filter(|event| event.get("eventType").and_then(Value::as_str) == Some("release.published"))
.filter_map(|event| {
let package = event.get("release")?.get("id")?.as_str()?;
let version = event.get("release")?.get("version")?.as_str()?;
Some(json!({ "id": package, "version": version }))
})
.collect()
}
fn registry_event_package_id(event: &Value) -> Option<String> {
if event.get("eventType").and_then(Value::as_str) == Some("release.published") {
return event
.get("release")?
.get("id")?
.as_str()
.map(str::to_string);
}
event
.get("package")
.and_then(Value::as_str)
.map(str::to_string)
}
fn array_len_at(value: &Value, field: &str) -> usize {
value
.get(field)
.and_then(Value::as_array)
.map_or(0, Vec::len)
}
fn json_bool(value: &Value, field: &str) -> Option<bool> {
value.get(field).and_then(Value::as_bool)
}
fn random_ed25519_key_id() -> String {
let mut bytes = [0_u8; 8];
OsRng.fill_bytes(&mut bytes);
format!("ed25519:{}", URL_SAFE_NO_PAD.encode(bytes))
}
fn random_nonce() -> String {
let mut bytes = [0_u8; 16];
OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn canonical_now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
}
fn normalize_repository(value: Option<&Value>) -> Option<String> {
match value {
Some(Value::String(value)) => Some(value.clone()),
Some(Value::Object(record)) => record
.get("url")
.and_then(Value::as_str)
.map(str::to_string),
_ => None,
}
}
fn is_package_export(value: &Value) -> bool {
match value {
Value::Null | Value::String(_) => true,
Value::Array(items) => items.iter().all(is_package_export),
Value::Object(record) => record.values().all(is_package_export),
_ => false,
}
}
fn normalize_string_array(value: &Value, field: &str) -> Result<Vec<Value>> {
let array = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("{field} must be an array"))?;
array
.iter()
.map(|item| {
let text = item
.as_str()
.ok_or_else(|| anyhow::anyhow!("{field} must contain non-empty strings"))?;
if text.is_empty() {
bail!("{field} must contain non-empty strings");
}
Ok(Value::String(text.to_string()))
})
.collect()
}
fn assert_known_fields(
record: &Map<String, Value>,
known_fields: &[&str],
label: &str,
) -> Result<()> {
let known = known_fields.iter().copied().collect::<BTreeSet<_>>();
if let Some(unknown) = record.keys().find(|key| !known.contains(key.as_str())) {
bail!("{label} must not include unknown field: {unknown}");
}
Ok(())
}
fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
value
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("{field} must be a string"))
}
fn assert_non_empty_no_control(value: &str, label: &str) -> Result<()> {
if value.is_empty() {
bail!("{label} must be a non-empty string");
}
if has_control_character(value) {
bail!("{label} must not include control characters");
}
Ok(())
}
fn has_control_character(value: &str) -> bool {
value.chars().any(|character| {
let code = character as u32;
code <= 0x1f || code == 0x7f
})
}
fn looks_like_windows_absolute_path(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/' && bytes[0].is_ascii_alphabetic()
}
fn trim_trailing_slashes(value: &str) -> &str {
value.trim_end_matches('/')
}