use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_REGISTRY_URL: &str = "https://registry.pakx.dev";
pub const CREDENTIALS_FILENAME: &str = "credentials.json";
#[derive(Debug, Error)]
pub enum CredentialsError {
#[error("could not resolve home directory")]
NoHomeDir,
#[error("credentials io error{path}: {source}", path = fmt_path(.path.as_ref()))]
Io {
#[source]
source: std::io::Error,
path: Option<PathBuf>,
},
#[error("credentials file malformed{path}: {source}", path = fmt_path(.path.as_ref()))]
Parse {
#[source]
source: serde_json::Error,
path: Option<PathBuf>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credentials {
#[serde(default)]
pub registries: BTreeMap<String, Entry>,
}
impl Credentials {
pub fn default_path() -> Result<PathBuf, CredentialsError> {
let home = dirs::home_dir().ok_or(CredentialsError::NoHomeDir)?;
Ok(home.join(".pakx").join(CREDENTIALS_FILENAME))
}
pub fn read_from(path: &Path) -> Result<Self, CredentialsError> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|source| CredentialsError::Parse {
source,
path: Some(path.to_path_buf()),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(CredentialsError::Io {
source,
path: Some(path.to_path_buf()),
}),
}
}
pub fn write_to(&self, path: &Path) -> Result<(), CredentialsError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| CredentialsError::Io {
source,
path: Some(parent.to_path_buf()),
})?;
}
let body = serde_json::to_vec_pretty(self).expect("BTreeMap<String, Entry> serializes");
let tmp_path = tmp_path_for(path);
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let (mut file, tmp_path) = match opts.open(&tmp_path) {
Ok(f) => (f, tmp_path),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
std::fs::remove_file(&tmp_path).map_err(|source| CredentialsError::Io {
source,
path: Some(tmp_path.clone()),
})?;
let retry_path = tmp_path_for_retry(path);
let f = opts
.open(&retry_path)
.map_err(|source| CredentialsError::Io {
source,
path: Some(retry_path.clone()),
})?;
(f, retry_path)
}
Err(source) => {
return Err(CredentialsError::Io {
source,
path: Some(tmp_path),
});
}
};
file.write_all(&body)
.map_err(|source| CredentialsError::Io {
source,
path: Some(tmp_path.clone()),
})?;
file.sync_all().map_err(|source| CredentialsError::Io {
source,
path: Some(tmp_path.clone()),
})?;
drop(file);
std::fs::rename(&tmp_path, path).map_err(|source| {
let _ = std::fs::remove_file(&tmp_path);
CredentialsError::Io {
source,
path: Some(path.to_path_buf()),
}
})?;
Ok(())
}
pub fn read_default() -> Result<Self, CredentialsError> {
let path = Self::default_path()?;
Self::read_from(&path)
}
#[must_use]
pub fn get(&self, registry_url: &str) -> Option<&Entry> {
let normalised = normalise(registry_url);
self.registries.get(&normalised)
}
pub fn set(&mut self, registry_url: &str, entry: Entry) -> Option<Entry> {
self.registries.insert(normalise(registry_url), entry)
}
pub fn remove(&mut self, registry_url: &str) -> Option<Entry> {
self.registries.remove(&normalise(registry_url))
}
}
fn normalise(url: &str) -> String {
url.trim_end_matches('/').to_lowercase()
}
fn tmp_path_for(path: &Path) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".tmp");
PathBuf::from(s)
}
fn tmp_path_for_retry(path: &Path) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let mut s = path.as_os_str().to_owned();
s.push(format!(".tmp.{}.{nanos}", std::process::id()));
PathBuf::from(s)
}
fn fmt_path(p: Option<&PathBuf>) -> String {
p.map_or_else(String::new, |path| format!(" at {}", redact(path)))
}
fn redact(path: &std::path::Path) -> String {
if let Ok(cwd) = std::env::current_dir() {
if let Ok(rel) = path.strip_prefix(&cwd) {
return rel.to_string_lossy().replace('\\', "/");
}
}
path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
}