use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{DodotError, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExternalsToml {
pub entries: BTreeMap<String, ExternalEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalEntry {
pub target: String,
#[serde(flatten)]
pub spec: FetchSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum FetchSpec {
File {
url: String,
sha256: String,
},
GitRepo {
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
subpath: Option<String>,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
commit: Option<String>,
},
Archive {
url: String,
sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<ArchiveFormat>,
},
ArchiveFile {
url: String,
sha256: String,
member: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<ArchiveFormat>,
},
#[serde(other)]
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ArchiveFormat {
TarGz,
Zip,
}
impl ArchiveFormat {
pub fn infer_from_url(url: &str) -> Option<Self> {
let stem = url.split(['?', '#']).next().unwrap_or(url);
let stem_lower = stem.to_ascii_lowercase();
if stem_lower.ends_with(".tar.gz") || stem_lower.ends_with(".tgz") {
Some(Self::TarGz)
} else if stem_lower.ends_with(".zip") {
Some(Self::Zip)
} else {
None
}
}
}
pub fn parse_externals_toml(bytes: &[u8]) -> Result<ExternalsToml> {
let text = std::str::from_utf8(bytes)
.map_err(|e| DodotError::Other(format!("externals.toml is not valid UTF-8: {e}")))?;
let parsed: BTreeMap<String, ExternalEntry> = toml::from_str(text)
.map_err(|e| DodotError::Other(format!("externals.toml parse error: {e}")))?;
for (name, entry) in &parsed {
validate_entry_name(name)?;
if let FetchSpec::GitRepo {
subpath,
git_ref,
commit,
..
} = &entry.spec
{
if git_ref.is_some() && commit.is_some() {
return Err(DodotError::Other(format!(
"externals.toml entry '{name}': `ref` and `commit` are mutually exclusive — pick one"
)));
}
if let Some(p) = subpath {
validate_subpath(name, p)?;
}
if let Some(c) = commit {
validate_commit_sha(name, c)?;
}
}
}
Ok(ExternalsToml { entries: parsed })
}
fn validate_subpath(entry: &str, subpath: &str) -> Result<()> {
if subpath.is_empty() {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `subpath` must not be empty"
)));
}
if subpath.starts_with('/') || subpath.starts_with('\\') {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `subpath` must be relative, not {subpath:?}"
)));
}
if subpath.starts_with('-') {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `subpath` must not start with `-` (git would treat it as an option): {subpath:?}"
)));
}
for segment in subpath.split(['/', '\\']) {
if segment == ".." {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `subpath` may not contain `..` segments: {subpath:?}"
)));
}
}
Ok(())
}
fn validate_commit_sha(entry: &str, commit: &str) -> Result<()> {
if commit.len() != 40 {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `commit` must be a full 40-char SHA, got {} chars",
commit.len()
)));
}
if !commit.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(DodotError::Other(format!(
"externals.toml entry '{entry}': `commit` must be hex (a-f, 0-9), got {commit:?}"
)));
}
Ok(())
}
fn validate_entry_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(DodotError::Other(
"externals.toml entry name must not be empty".into(),
));
}
if name == "." || name == ".." {
return Err(DodotError::Other(format!(
"externals.toml entry name {name:?} is reserved"
)));
}
if name.starts_with('.') {
return Err(DodotError::Other(format!(
"externals.toml entry name {name:?} must not start with a dot"
)));
}
for ch in name.chars() {
let ok = ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.');
if !ok {
return Err(DodotError::Other(format!(
"externals.toml entry name {name:?} contains invalid character {ch:?}; \
allowed: ASCII letters, digits, `-`, `_`, `.`"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_file_entry() {
let toml = r#"
[aliases]
type = "file"
url = "https://example.com/aliases.sh"
target = "~/.config/shared/aliases.sh"
sha256 = "deadbeef"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
assert_eq!(parsed.entries.len(), 1);
let entry = parsed.entries.get("aliases").unwrap();
assert_eq!(entry.target, "~/.config/shared/aliases.sh");
match &entry.spec {
FetchSpec::File { url, sha256 } => {
assert_eq!(url, "https://example.com/aliases.sh");
assert_eq!(sha256, "deadbeef");
}
other => panic!("expected File spec, got {other:?}"),
}
}
#[test]
fn entries_are_alphabetical() {
let toml = r#"
[zeta]
type = "file"
url = "https://example.com/z"
target = "~/z"
sha256 = "00"
[alpha]
type = "file"
url = "https://example.com/a"
target = "~/a"
sha256 = "11"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
let names: Vec<&str> = parsed.entries.keys().map(String::as_str).collect();
assert_eq!(names, vec!["alpha", "zeta"]);
}
#[test]
fn rejects_entry_names_with_path_separator() {
let toml = r#"
["bad/name"]
type = "file"
url = "https://example.com/x"
target = "~/x"
sha256 = "00"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("invalid character"), "got: {msg}");
}
#[test]
fn rejects_dotdot_entry_name() {
let toml = r#"
[".."]
type = "file"
url = "https://example.com/x"
target = "~/x"
sha256 = "00"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("reserved"));
}
#[test]
fn rejects_dot_prefixed_entry_name() {
let toml = r#"
[".hidden"]
type = "file"
url = "https://example.com/x"
target = "~/x"
sha256 = "00"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("must not start with a dot"));
}
#[test]
fn accepts_internal_dots_dashes_underscores() {
let toml = r#"
["shared.aliases-v2_main"]
type = "file"
url = "https://example.com/x"
target = "~/x"
sha256 = "00"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
assert!(parsed.entries.contains_key("shared.aliases-v2_main"));
}
#[test]
fn parses_git_repo_entry_minimal() {
let toml = r#"
[omz]
type = "git-repo"
url = "https://github.com/ohmyzsh/ohmyzsh.git"
target = "~/.oh-my-zsh"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
let entry = parsed.entries.get("omz").unwrap();
assert_eq!(entry.target, "~/.oh-my-zsh");
match &entry.spec {
FetchSpec::GitRepo {
url,
subpath,
git_ref,
commit,
} => {
assert_eq!(url, "https://github.com/ohmyzsh/ohmyzsh.git");
assert!(subpath.is_none());
assert!(git_ref.is_none());
assert!(commit.is_none());
}
other => panic!("expected GitRepo spec, got {other:?}"),
}
}
#[test]
fn parses_git_repo_entry_with_subpath_and_ref() {
let toml = r#"
[p10k]
type = "git-repo"
url = "https://github.com/romkatv/powerlevel10k.git"
target = "~/.config/zsh/themes/p10k"
subpath = "themes"
ref = "v1.20.0"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
let entry = parsed.entries.get("p10k").unwrap();
match &entry.spec {
FetchSpec::GitRepo {
subpath,
git_ref,
commit,
..
} => {
assert_eq!(subpath.as_deref(), Some("themes"));
assert_eq!(git_ref.as_deref(), Some("v1.20.0"));
assert!(commit.is_none());
}
other => panic!("expected GitRepo, got {other:?}"),
}
}
#[test]
fn parses_git_repo_entry_with_commit_pin() {
let toml = r#"
[tpm]
type = "git-repo"
url = "https://github.com/tmux-plugins/tpm.git"
target = "~/.tmux/plugins/tpm"
commit = "3a8b3f4a5b8d1c2e3f4a5b6c7d8e9f0a1b2c3d4e"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
let entry = parsed.entries.get("tpm").unwrap();
match &entry.spec {
FetchSpec::GitRepo { commit, .. } => {
assert_eq!(
commit.as_deref(),
Some("3a8b3f4a5b8d1c2e3f4a5b6c7d8e9f0a1b2c3d4e")
);
}
other => panic!("expected GitRepo, got {other:?}"),
}
}
#[test]
fn rejects_absolute_subpath() {
let toml = r#"
[bad]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
subpath = "/etc"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("must be relative"));
}
#[test]
fn rejects_dotdot_in_subpath() {
let toml = r#"
[bad]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
subpath = "themes/../../etc"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains(".."));
}
#[test]
fn rejects_dash_prefixed_subpath() {
let toml = r#"
[bad]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
subpath = "-experimental"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("must not start with `-`"));
}
#[test]
fn rejects_short_commit_sha() {
let toml = r#"
[bad]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
commit = "abc1234"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("full 40-char SHA"));
}
#[test]
fn rejects_non_hex_commit_sha() {
let toml = r#"
[bad]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
commit = "zzzzbeef1234567890abcdef1234567890abcdef"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("must be hex"));
}
#[test]
fn rejects_ref_and_commit_simultaneously() {
let toml = r#"
[conflicted]
type = "git-repo"
url = "https://example.com/x.git"
target = "~/x"
ref = "v1"
commit = "abc1234"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("mutually exclusive"), "got: {msg}");
assert!(msg.contains("conflicted"), "got: {msg}");
}
#[test]
fn unknown_type_becomes_unsupported() {
let toml = r#"
[bogus]
type = "this-will-never-exist"
url = "https://example.com"
target = "~/x"
"#;
let parsed = parse_externals_toml(toml.as_bytes()).unwrap();
let entry = parsed.entries.get("bogus").unwrap();
assert!(matches!(entry.spec, FetchSpec::Unsupported));
}
#[test]
fn rejects_malformed_toml() {
let err = parse_externals_toml(b"this is not = valid [[ toml").unwrap_err();
assert!(format!("{err}").contains("externals.toml parse error"));
}
#[test]
fn rejects_invalid_utf8() {
let err = parse_externals_toml(&[0xff, 0xfe, 0xfd]).unwrap_err();
assert!(format!("{err}").contains("not valid UTF-8"));
}
#[test]
fn missing_sha256_on_file_is_an_error() {
let toml = r#"
[unpinned]
type = "file"
url = "https://example.com/x.sh"
target = "~/x.sh"
"#;
let err = parse_externals_toml(toml.as_bytes()).unwrap_err();
assert!(format!("{err}").contains("parse error"));
}
}