From 9fec37759cb83ebd267ba8094a94d30900189d89 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 3 May 2026 20:35:08 -0400 Subject: [PATCH] Add repository manifest identity Add the .build-eips.repo.toml schema, loader, validation rules, and manifest tests for active proposal repositories and declared sibling repositories. Introduce ActiveRepoIdentity so later workspace lifecycle and execution layers can select manifest-backed repository metadata while the legacy EIPs/ERCs fallback continues to operate. --- Cargo.lock | 43 +++- Cargo.toml | 3 + src/config.rs | 547 +++++++++++++++++++++++++++++++++++++++++++++++- src/identity.rs | 70 +++++++ src/main.rs | 1 + 5 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 src/identity.rs diff --git a/Cargo.lock b/Cargo.lock index dbca5a3..aa9d0aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,7 @@ dependencies = [ "serde_json", "sha3", "snafu", + "tempfile", "tokio", "toml 0.9.11+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0", @@ -785,6 +786,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "figment" version = "0.10.19" @@ -1341,9 +1348,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libgit2-sys" @@ -1395,6 +1402,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -2007,6 +2020,19 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2424,6 +2450,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" diff --git a/Cargo.toml b/Cargo.toml index d87e3e7..4529c21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,3 +53,6 @@ iref = "3.2.2" [features] backtrace = [ "snafu/backtrace", "eipw-lint/backtrace" ] + +[dev-dependencies] +tempfile = "3.23.0" diff --git a/src/config.rs b/src/config.rs index 26c4ad4..10f2098 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,324 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use std::collections::HashMap; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; +use snafu::{Backtrace, IntoError, OptionExt, ResultExt, Snafu}; use url::Url; +pub const REPO_MANIFEST_FILE: &str = ".build-eips.repo.toml"; +const RESERVED_REPO_IDS: &[&str] = &["theme", "preprocessor", "eipw"]; + +#[derive(Debug, Snafu)] +pub enum RepoManifestError { + #[snafu(display("i/o error while accessing `{}`", path.to_string_lossy()))] + Io { + path: PathBuf, + source: std::io::Error, + backtrace: Backtrace, + }, + + #[snafu(display( + "unable to parse repo manifest `{}`", + manifest_path.to_string_lossy() + ))] + Parse { + manifest_path: PathBuf, + #[snafu(source(from(toml::de::Error, Box::new)))] + source: Box, + backtrace: Backtrace, + }, + + #[snafu(display( + "repo manifest `{}` is invalid: {reason}", + manifest_path.to_string_lossy() + ))] + Invalid { + manifest_path: PathBuf, + reason: String, + backtrace: Backtrace, + }, +} + +/// Environment-specific repository metadata for an active proposal repo or sibling repo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepositoryEndpoint { + /// Git repository to fetch proposal content from. + pub repository: Url, + + /// Base URL where rendered HTML and assets for this repository are served. + pub base_url: Url, +} + +/// Tracked active-repo manifest loaded from `.build-eips.repo.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepoManifest { + /// Stable machine key for workspace directory names, build roots, and sibling references. + pub repo_id: String, + + /// Production repository and base URL for this active repo. + pub production: RepositoryEndpoint, + + /// Staging repository and base URL for this active repo. + pub staging: RepositoryEndpoint, + + /// Directional sibling content repos used by this active repo. + #[serde(default)] + pub siblings: BTreeMap, +} + +impl RepoManifest { + fn from_raw(raw: RawRepoManifest, manifest_path: &Path) -> Result { + let repo_id = Self::required_value(manifest_path, "repo_id", raw.repo_id)?; + let production = Self::required_value(manifest_path, "production", raw.production)?; + let staging = Self::required_value(manifest_path, "staging", raw.staging)?; + let siblings = raw + .siblings + .into_iter() + .map(|(repo_id, sibling)| { + let production = Self::required_value( + manifest_path, + &format!("siblings.{repo_id}.production"), + sibling.production, + )?; + let staging = Self::required_value( + manifest_path, + &format!("siblings.{repo_id}.staging"), + sibling.staging, + )?; + + Ok(( + repo_id, + RepoManifestSibling { + production, + staging, + }, + )) + }) + .collect::>()?; + + let manifest = Self { + repo_id, + production, + staging, + siblings, + }; + manifest.validate(manifest_path)?; + Ok(manifest) + } + + fn validate(&self, manifest_path: &Path) -> Result<(), RepoManifestError> { + Self::validate_repo_key(manifest_path, "repo_id", &self.repo_id)?; + + if self.siblings.contains_key(&self.repo_id) { + return InvalidSnafu { + manifest_path: manifest_path.to_path_buf(), + reason: format!( + "repo_id `{}` cannot also be declared as a sibling", + self.repo_id + ), + } + .fail(); + } + + for sibling_id in self.siblings.keys() { + Self::validate_repo_key(manifest_path, "sibling key", sibling_id)?; + } + + Self::validate_unique_sibling_repositories( + manifest_path, + "production", + self.siblings + .iter() + .map(|(id, sibling)| (id.as_str(), sibling.production.repository.as_str())), + )?; + Self::validate_unique_sibling_repositories( + manifest_path, + "staging", + self.siblings + .iter() + .map(|(id, sibling)| (id.as_str(), sibling.staging.repository.as_str())), + )?; + + Ok(()) + } + + pub fn active_endpoint(&self, staging: bool) -> &RepositoryEndpoint { + if staging { + &self.staging + } else { + &self.production + } + } + + pub fn sibling_repositories(&self, staging: bool) -> BTreeMap { + self.siblings + .iter() + .map(|(repo_id, sibling)| { + let endpoint = if staging { + &sibling.staging + } else { + &sibling.production + }; + (repo_id.clone(), endpoint.repository.clone()) + }) + .collect() + } + + fn required_value( + manifest_path: &Path, + field: &str, + value: Option, + ) -> Result { + value.with_context(|| InvalidSnafu { + manifest_path: manifest_path.to_path_buf(), + reason: format!("missing required `{field}` entry"), + }) + } + + fn validate_repo_key( + manifest_path: &Path, + label: &str, + key: &str, + ) -> Result<(), RepoManifestError> { + let invalid_reason = if key.is_empty() { + Some("must not be empty") + } else if matches!(key, "." | "..") { + Some("must not be `.` or `..`") + } else if key.contains('/') || key.contains('\\') { + Some("must be a single safe path component") + } else if RESERVED_REPO_IDS.contains(&key) { + Some("collides with a reserved workspace/platform directory name") + } else { + None + }; + + if let Some(reason) = invalid_reason { + return InvalidSnafu { + manifest_path: manifest_path.to_path_buf(), + reason: format!("{label} `{key}` {reason}"), + } + .fail(); + } + + Ok(()) + } + + fn validate_unique_sibling_repositories<'a>( + manifest_path: &Path, + environment: &str, + siblings: impl Iterator, + ) -> Result<(), RepoManifestError> { + let mut seen = HashSet::new(); + for (repo_id, repository) in siblings { + if !seen.insert(repository) { + return InvalidSnafu { + manifest_path: manifest_path.to_path_buf(), + reason: format!( + "duplicate {environment} sibling repository declaration `{repository}` under sibling key `{repo_id}`" + ), + } + .fail(); + } + } + + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRepoManifest { + repo_id: Option, + production: Option, + staging: Option, + #[serde(default)] + siblings: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRepoManifestSibling { + production: Option, + staging: Option, +} + +/// Environment-specific metadata for one declared sibling content repo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepoManifestSibling { + /// Production repository and base URL for this sibling repo. + pub production: RepositoryEndpoint, + + /// Staging repository and base URL for this sibling repo. + pub staging: RepositoryEndpoint, +} + +#[derive(Debug, Clone)] +pub struct LoadedRepoManifest { + manifest_path: PathBuf, + manifest: RepoManifest, +} + +impl LoadedRepoManifest { + pub fn load(repo_root: &Path) -> Result, RepoManifestError> { + let manifest_path = repo_root.join(REPO_MANIFEST_FILE); + match std::fs::read_to_string(&manifest_path) { + Ok(contents) => Self::from_contents(manifest_path, &contents).map(Some), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + Ok(None) + } + Err(error) => Err(IoSnafu { + path: manifest_path, + } + .into_error(error)), + } + } + + #[cfg(test)] + pub fn from_path(path: &Path) -> Result { + let manifest_path = path.canonicalize().with_context(|_| IoSnafu { + path: path.to_path_buf(), + })?; + let contents = std::fs::read_to_string(&manifest_path).with_context(|_| IoSnafu { + path: manifest_path.clone(), + })?; + Self::from_contents(manifest_path, &contents) + } + + fn from_contents(manifest_path: PathBuf, contents: &str) -> Result { + let manifest = + toml::from_str::(contents).with_context(|_| ParseSnafu { + manifest_path: manifest_path.clone(), + })?; + let manifest = RepoManifest::from_raw(manifest, &manifest_path)?; + + Ok(Self { + manifest_path, + manifest, + }) + } + + pub fn manifest_path(&self) -> &Path { + &self.manifest_path + } + + pub fn manifest(&self) -> &RepoManifest { + &self.manifest + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { /// Where to fetch the theme from. @@ -106,3 +419,235 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use tempfile::TempDir; + + use super::{LoadedRepoManifest, RepoManifestError, REPO_MANIFEST_FILE}; + + struct TestRepo { + tempdir: TempDir, + } + + impl TestRepo { + fn new() -> Self { + Self { + tempdir: TempDir::new().unwrap(), + } + } + + fn root(&self) -> &Path { + self.tempdir.path() + } + + fn path(&self, relative: impl AsRef) -> PathBuf { + self.root().join(relative) + } + + fn write_file(&self, relative: impl AsRef, contents: &str) -> PathBuf { + let path = self.path(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, contents).unwrap(); + path + } + } + + fn manifest_text(repo_id: &str, siblings: &str) -> String { + format!( + r#" +repo_id = "{repo_id}" + +[production] +repository = "https://example.test/{repo_id}.git" +base_url = "https://example.test/{repo_id}/" + +[staging] +repository = "https://staging.example.test/{repo_id}.git" +base_url = "https://staging.example.test/{repo_id}/" + +{siblings} +"# + ) + } + + fn manifest_invalid_reason(error: RepoManifestError) -> String { + match error { + RepoManifestError::Invalid { reason, .. } => reason, + other => panic!("expected invalid repo manifest, got {other:?}"), + } + } + + #[test] + fn missing_repo_manifest_loads_as_none() { + let repo = TestRepo::new(); + + assert!(LoadedRepoManifest::load(repo.root()).unwrap().is_none()); + } + + #[test] + fn malformed_repo_manifest_reports_parse_error() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file(REPO_MANIFEST_FILE, "repo_id = ["); + + let error = LoadedRepoManifest::from_path(&manifest_path).unwrap_err(); + + assert!(matches!(error, RepoManifestError::Parse { .. })); + } + + #[test] + fn parses_repo_manifest_with_directional_siblings() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + &manifest_text( + "Core", + r#" +[siblings.EIPs.production] +repository = "https://example.test/EIPs.git" +base_url = "https://example.test/EIPs/" + +[siblings.EIPs.staging] +repository = "https://staging.example.test/EIPs.git" +base_url = "https://staging.example.test/EIPs/" +"#, + ), + ); + + let manifest = LoadedRepoManifest::from_path(&manifest_path).unwrap(); + + assert_eq!(manifest.manifest().repo_id, "Core"); + assert_eq!(manifest.manifest().siblings.len(), 1); + assert!(manifest.manifest().siblings.contains_key("EIPs")); + } + + #[test] + fn repo_manifest_requires_identity_and_environments() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + r#" +[production] +repository = "https://example.test/Core.git" +base_url = "https://example.test/Core/" +"#, + ); + + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("missing required `repo_id` entry")); + + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + r#" +repo_id = "Core" + +[production] +repository = "https://example.test/Core.git" +base_url = "https://example.test/Core/" +"#, + ); + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("missing required `staging` entry")); + + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + r#" +repo_id = "Core" + +[staging] +repository = "https://staging.example.test/Core.git" +base_url = "https://staging.example.test/Core/" +"#, + ); + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("missing required `production` entry")); + } + + #[test] + fn repo_manifest_rejects_unsafe_and_reserved_keys() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file(REPO_MANIFEST_FILE, &manifest_text("theme", "")); + + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("repo_id `theme`")); + assert!(reason.contains("reserved")); + + let manifest_path = repo.write_file(REPO_MANIFEST_FILE, &manifest_text("Core/Meta", "")); + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("repo_id `Core/Meta`")); + assert!(reason.contains("single safe path component")); + } + + #[test] + fn repo_manifest_rejects_self_sibling() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + &manifest_text( + "Core", + r#" +[siblings.Core.production] +repository = "https://example.test/Core.git" +base_url = "https://example.test/Core/" + +[siblings.Core.staging] +repository = "https://staging.example.test/Core.git" +base_url = "https://staging.example.test/Core/" +"#, + ), + ); + + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("cannot also be declared as a sibling")); + } + + #[test] + fn repo_manifest_rejects_duplicate_sibling_repositories() { + let repo = TestRepo::new(); + let manifest_path = repo.write_file( + REPO_MANIFEST_FILE, + &manifest_text( + "Core", + r#" +[siblings.EIPs.production] +repository = "https://example.test/shared.git" +base_url = "https://example.test/EIPs/" + +[siblings.EIPs.staging] +repository = "https://staging.example.test/EIPs.git" +base_url = "https://staging.example.test/EIPs/" + +[siblings.ERCs.production] +repository = "https://example.test/shared.git" +base_url = "https://example.test/ERCs/" + +[siblings.ERCs.staging] +repository = "https://staging.example.test/ERCs.git" +base_url = "https://staging.example.test/ERCs/" +"#, + ), + ); + + let reason = + manifest_invalid_reason(LoadedRepoManifest::from_path(&manifest_path).unwrap_err()); + + assert!(reason.contains("duplicate production sibling repository")); + assert!(reason.contains("https://example.test/shared.git")); + } +} diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..cbb73c9 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,70 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Active repository identity selection. + +use std::path::Path; + +use snafu::{ResultExt, Whatever}; + +use crate::{ + config::{self, Config, LoadedRepoManifest}, + git, +}; + +#[derive(Debug, Clone)] +pub(crate) enum ActiveRepoIdentity { + Manifest(Box), + Legacy { repo_id: String }, +} + +impl ActiveRepoIdentity { + pub(crate) fn load(root_path: &Path) -> Result { + if let Some(manifest) = + LoadedRepoManifest::load(root_path).whatever_context("unable to load repo manifest")? + { + return Ok(Self::Manifest(Box::new(manifest))); + } + + match Config::production() + .locations + .identify_repository(root_path) + { + Ok(repository_use) => Ok(Self::Legacy { + repo_id: repository_use.title, + }), + Err(git::Error::NoIdentify { .. }) => { + snafu::whatever!( + "active repository `{}` does not carry `{}` and does not match the legacy EIPs/ERCs identity fallback", + root_path.to_string_lossy(), + config::REPO_MANIFEST_FILE + ) + } + Err(error) => Err(error).whatever_context("cannot identify legacy repository use"), + } + } + + pub(crate) fn repo_id(&self) -> &str { + match self { + Self::Manifest(manifest) => &manifest.manifest().repo_id, + Self::Legacy { repo_id } => repo_id, + } + } + + pub(crate) fn source_description(&self) -> &'static str { + match self { + Self::Manifest(_) => "repo manifest", + Self::Legacy { .. } => "legacy EIPs/ERCs fallback", + } + } + + pub(crate) fn manifest(&self) -> Option<&LoadedRepoManifest> { + match self { + Self::Manifest(manifest) => Some(manifest.as_ref()), + Self::Legacy { .. } => None, + } + } +} diff --git a/src/main.rs b/src/main.rs index bc01379..bc0449b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod context; mod find_root; mod git; mod github; +mod identity; mod layout; mod lint; mod markdown;