use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
static ALIAS_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"^[a-z0-9][a-z0-9._-]*$").expect("alias pattern is valid")
});
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegistryEntry {
pub alias: String,
pub url: String,
pub git_ref: String,
pub created_at: String,
}
#[derive(Debug, Default)]
pub struct ManagedRegistry {
entries: Vec<RegistryEntry>,
path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error(
"alias '{alias}' is already registered as '{}'; use --force to overwrite",
crate::core::remote_url_redact::redact_stored_url(.existing_url)
)]
DuplicateAlias {
alias: String,
existing_url: crate::core::remote_url_redact::RedactedUrl,
},
#[error("invalid alias '{alias}': must match ^[a-z0-9][a-z0-9._-]*$")]
InvalidAlias {
alias: String,
},
#[error("alias '{alias}' is not registered; run `tm register <owner/repo> {alias}` first")]
NotFound {
alias: String,
},
#[error("registry io error for {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("registry json error: {0}")]
Json(#[from] serde_json::Error),
}
impl ManagedRegistry {
pub fn load(managed_root: &Path) -> Result<Self, RegistryError> {
let path = managed_root.join("registry.json");
std::fs::create_dir_all(managed_root).map_err(|source| RegistryError::Io {
path: managed_root.to_path_buf(),
source,
})?;
let entries = match std::fs::read_to_string(&path) {
Ok(text) => {
serde_json::from_str::<Vec<RegistryEntry>>(&text)?
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(source) => {
return Err(RegistryError::Io {
path: path.clone(),
source,
});
}
};
Ok(Self { entries, path })
}
pub fn save(&self) -> Result<(), RegistryError> {
let serialized = serde_json::to_string_pretty(&self.entries)?;
let tmp_path = self.path.with_extension("json.tmp");
std::fs::write(&tmp_path, &serialized).map_err(|source| RegistryError::Io {
path: tmp_path.clone(),
source,
})?;
if let Err(rename_err) = std::fs::rename(&tmp_path, &self.path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(RegistryError::Io {
path: self.path.clone(),
source: rename_err,
});
}
Ok(())
}
pub fn add(&mut self, alias: &str, url: &str, force: bool) -> Result<(), RegistryError> {
validate_alias(alias)?;
if let Some(existing) = self.entries.iter().find(|e| e.alias == alias) {
if existing.url == url {
return Ok(());
}
if !force {
return Err(RegistryError::DuplicateAlias {
alias: alias.to_string(),
existing_url: existing.url.clone().into(),
});
}
self.entries.retain(|e| e.alias != alias);
}
let now = chrono::Utc::now().to_rfc3339();
self.entries.push(RegistryEntry {
alias: alias.to_string(),
url: url.to_string(),
git_ref: "default".to_string(),
created_at: now,
});
Ok(())
}
pub fn remove(&mut self, alias: &str) -> Result<(), RegistryError> {
let before = self.entries.len();
self.entries.retain(|e| e.alias != alias);
if self.entries.len() == before {
return Err(RegistryError::NotFound {
alias: alias.to_string(),
});
}
Ok(())
}
pub fn get(&self, alias: &str) -> Result<&RegistryEntry, RegistryError> {
self.entries
.iter()
.find(|e| e.alias == alias)
.ok_or_else(|| RegistryError::NotFound {
alias: alias.to_string(),
})
}
pub fn list(&self) -> Vec<RegistryEntry> {
let mut v = self.entries.clone();
v.sort_by(|a, b| a.alias.cmp(&b.alias));
v
}
pub fn is_loaded(&self, alias: &str, managed_root: &Path) -> bool {
managed_root
.join("projects")
.join(alias)
.join("repo")
.join(".trusty-mpm")
.join("managed.toml")
.exists()
}
}
fn validate_alias(alias: &str) -> Result<(), RegistryError> {
if !ALIAS_RE.is_match(alias) {
return Err(RegistryError::InvalidAlias {
alias: alias.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp_root() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let path = dir.path().to_path_buf();
(dir, path)
}
#[test]
fn test_registry_add_and_list() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("my-project", "https://github.com/org/repo", false)
.unwrap();
reg.save().unwrap();
let reg2 = ManagedRegistry::load(&root).unwrap();
let entries = reg2.list();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "my-project");
assert_eq!(entries[0].url, "https://github.com/org/repo");
assert_eq!(
entries[0].git_ref, "default",
"git_ref must be the 'default' sentinel, not a literal branch name"
);
}
#[test]
fn test_registry_duplicate_rejects_without_force() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("proj", "https://github.com/org/a", false).unwrap();
let err = reg
.add("proj", "https://github.com/org/b", false)
.unwrap_err();
assert!(matches!(err, RegistryError::DuplicateAlias { .. }));
}
#[test]
fn test_registry_duplicate_allows_with_force() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("proj", "https://github.com/org/a", false).unwrap();
reg.add("proj", "https://github.com/org/b", true).unwrap();
assert_eq!(reg.list().len(), 1);
assert_eq!(reg.list()[0].url, "https://github.com/org/b");
}
#[test]
fn test_alias_validation_rejects_invalid() {
assert!(validate_alias("My-Project").is_err());
assert!(validate_alias("").is_err());
assert!(validate_alias("-bad").is_err());
assert!(validate_alias("ok").is_ok());
assert!(validate_alias("ok-1.2").is_ok());
}
#[test]
fn test_registry_same_url_is_noop() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("proj", "https://github.com/org/a", false).unwrap();
reg.add("proj", "https://github.com/org/a", false).unwrap();
assert_eq!(reg.list().len(), 1);
}
#[test]
fn test_load_malformed_errors_not_empty() {
let (_dir, root) = tmp_root();
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("registry.json"), b"[{\"alias\":\"broken\"").unwrap();
let result = ManagedRegistry::load(&root);
assert!(
result.is_err(),
"load of malformed registry.json must return Err, not empty"
);
assert!(
matches!(result.unwrap_err(), RegistryError::Json(_)),
"error must be RegistryError::Json for a parse failure"
);
let still_malformed = std::fs::read_to_string(root.join("registry.json")).unwrap();
assert!(
still_malformed.contains("broken"),
"malformed file must not be overwritten after a failed load"
);
}
#[test]
fn test_load_missing_is_empty() {
let (_dir, root) = tmp_root();
let reg = ManagedRegistry::load(&root).unwrap();
assert!(reg.list().is_empty(), "fresh load must have zero entries");
}
#[test]
fn test_atomic_save_tmp_not_left_on_success() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("alpha", "https://github.com/org/alpha", false)
.unwrap();
reg.save().unwrap();
assert!(
!root.join("registry.json.tmp").exists(),
"#1550 item 1: no .tmp file must remain after a successful save"
);
}
#[test]
fn test_atomic_save_clears_stale_tmp() {
let (_dir, root) = tmp_root();
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("registry.json.tmp"), b"stale").unwrap();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("beta", "https://github.com/org/beta", false)
.unwrap();
reg.save().unwrap();
assert!(
!root.join("registry.json.tmp").exists(),
"#1550 item 1: stale .tmp must be cleared after a successful save"
);
let reg2 = ManagedRegistry::load(&root).unwrap();
assert_eq!(reg2.list().len(), 1);
assert_eq!(reg2.list()[0].alias, "beta");
}
#[test]
fn test_atomic_save_round_trip() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("alpha", "https://github.com/org/alpha", false)
.unwrap();
reg.add("beta", "https://github.com/org/beta", false)
.unwrap();
reg.save().unwrap();
assert!(
!root.join("registry.json.tmp").exists(),
"no .tmp file should remain after a successful save"
);
let reg2 = ManagedRegistry::load(&root).unwrap();
let entries = reg2.list();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].alias, "alpha");
assert_eq!(entries[1].alias, "beta");
}
#[test]
fn test_register_existing_alias_without_force_errors() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("myproj", "https://github.com/org/a", false)
.unwrap();
reg.save().unwrap();
let mut reg2 = ManagedRegistry::load(&root).unwrap();
let err = reg2
.add("myproj", "https://github.com/org/b", false)
.unwrap_err();
assert!(
matches!(err, RegistryError::DuplicateAlias { ref alias, .. } if alias == "myproj"),
"must return DuplicateAlias error when alias exists and --force is not set"
);
let reg3 = ManagedRegistry::load(&root).unwrap();
assert_eq!(reg3.get("myproj").unwrap().url, "https://github.com/org/a");
}
#[test]
fn test_register_existing_alias_with_force_succeeds() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("myproj", "https://github.com/org/a", false)
.unwrap();
reg.save().unwrap();
let mut reg2 = ManagedRegistry::load(&root).unwrap();
reg2.add("myproj", "https://github.com/org/b", true)
.unwrap();
reg2.save().unwrap();
let reg3 = ManagedRegistry::load(&root).unwrap();
assert_eq!(
reg3.list().len(),
1,
"still exactly one entry after force-overwrite"
);
assert_eq!(
reg3.get("myproj").unwrap().url,
"https://github.com/org/b",
"URL must be updated after --force overwrite"
);
}
#[test]
fn duplicate_alias_error_never_shows_a_stored_token_9155() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
reg.add("myproj", "https://qauser:SECRETQATOKEN2@h/org/a", false)
.unwrap();
let err = reg.add("myproj", "https://h/org/b", false).unwrap_err();
for text in [err.to_string(), format!("{err:?}")] {
assert!(!text.contains("SECRETQATOKEN2"), "{text}");
assert!(text.contains("h/org/a"), "{text}");
}
}
#[test]
fn duplicate_alias_message_masks_a_quoted_password_9227() {
for (i, sep) in ["'", " ", "\"", "`"].into_iter().enumerate() {
let (_dir, root) = tmp_root();
let mut reg = ManagedRegistry::load(&root).unwrap();
let stored = format!("https://u:pa{sep}ss9227@host/o/r");
reg.add("myproj", &stored, false).unwrap();
let msg = reg
.add("myproj", "https://host/o/b", false)
.unwrap_err()
.to_string();
assert!(
!msg.contains("ss9227"),
"case {i}: a password fragment survived"
);
assert!(
!msg.contains("u:pa"),
"case {i}: the user:password pair survived"
);
assert!(
msg.contains("'https://***@host/o/r'"),
"case {i}: URL not masked"
);
}
}
}