From 051e4010b5835b18eaee2722fa6fbae6f1a2bd60 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Thu, 6 Aug 2026 21:02:39 +0800 Subject: [PATCH 1/6] feat: load configuration from https --- Cargo.lock | 2 + dot-cli/Cargo.toml | 2 + dot-cli/src/config.rs | 336 +++++++++++++++++++++++++++++++----- dot-cli/src/config/https.rs | 194 +++++++++++++++++++++ dot-cli/src/main.rs | 15 +- dot-cli/tests/cli.rs | 11 +- 6 files changed, 508 insertions(+), 52 deletions(-) create mode 100644 dot-cli/src/config/https.rs diff --git a/Cargo.lock b/Cargo.lock index febca73..98e1f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,8 @@ dependencies = [ "serde", "thiserror", "toml", + "ureq", + "url", ] [[package]] diff --git a/dot-cli/Cargo.toml b/dot-cli/Cargo.toml index 65bff22..cb7a5e5 100644 --- a/dot-cli/Cargo.toml +++ b/dot-cli/Cargo.toml @@ -19,3 +19,5 @@ dot-core = { path = ".." } serde = { version = "1.0", features = ["derive"], optional = true } thiserror = "2" toml = "1.1" +ureq = { version = "3.3", default-features = false, features = ["rustls"] } +url = "2.5" diff --git a/dot-cli/src/config.rs b/dot-cli/src/config.rs index 92fa489..d573766 100644 --- a/dot-cli/src/config.rs +++ b/dot-cli/src/config.rs @@ -1,4 +1,4 @@ -//! Local configuration discovery and filesystem loading. +//! Configuration source parsing, local discovery, and loading. #![expect( clippy::result_large_err, @@ -9,16 +9,88 @@ use std::env; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::str::FromStr; use directories::BaseDirs; +use url::Url; use dot_core::config::ConfigParseError; use dot_core::schema::Config; use dot_core::validation::ConfigValidationError; use dot_core::{ConfigFile, ConfigFileError}; +mod https; + +pub(crate) use https::HttpsError; + const DEFAULT_CONFIG_FILENAME: &str = ".dot.toml"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ConfigSource { + Path(PathBuf), + Https(Url), +} + +impl FromStr for ConfigSource { + type Err = ConfigSourceError; + + fn from_str(value: &str) -> Result { + if is_windows_drive_rooted(value) { + return Ok(Self::Path(PathBuf::from(value))); + } + + let Some(scheme) = explicit_scheme(value) else { + return Ok(Self::Path(PathBuf::from(value))); + }; + + if scheme.eq_ignore_ascii_case("https") { + Url::parse(value).map(Self::Https).map_err(|source| { + ConfigSourceError::InvalidHttpsUrl { + value: value.to_owned(), + source, + } + }) + } else { + Err(ConfigSourceError::UnsupportedScheme { + scheme: scheme.to_ascii_lowercase(), + }) + } + } +} + +fn is_windows_drive_rooted(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} + +fn explicit_scheme(value: &str) -> Option<&str> { + let (scheme, _) = value.split_once("://")?; + let mut characters = scheme.chars(); + let first = characters.next()?; + (first.is_ascii_alphabetic() + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + })) + .then_some(scheme) +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ConfigSourceError { + #[error( + "configuration source protocol `{scheme}` is not supported; use HTTPS or a filesystem path" + )] + UnsupportedScheme { scheme: String }, + #[error("invalid HTTPS configuration source `{value}`: {source}")] + InvalidHttpsUrl { + value: String, + #[source] + source: url::ParseError, + }, +} + fn path_entry_exists(path: &Path) -> io::Result { match fs::symlink_metadata(path) { Ok(_) => Ok(true), @@ -46,13 +118,13 @@ fn detect_user_config_root() -> Option { } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum ConfigLocation { - Path(PathBuf), +pub(crate) enum ConfigRequest { Discover, + Source(ConfigSource), } -impl ConfigLocation { - fn resolve(self, invocation_cwd: &Path) -> Result { +impl ConfigRequest { + fn resolve(self, invocation_cwd: &Path) -> Result { self.resolve_with(invocation_cwd, detect_user_config_root, path_entry_exists) } } @@ -79,7 +151,7 @@ impl UserConfigRoot { } #[derive(Debug, thiserror::Error)] -pub enum ConfigDiscoveryError { +pub(crate) enum ConfigDiscoveryError { #[error("failed to determine the user configuration directory")] UserDirectoryUnavailable, #[error( @@ -92,26 +164,26 @@ pub enum ConfigDiscoveryError { source: io::Error, }, #[error( - "configuration not found; checked `{}` then `{}`; use --config PATH to select another file", + "configuration not found; checked `{}` then `{}`; use --config SOURCE to select another file", .local.display(), .user.display() )] NotFound { local: PathBuf, user: PathBuf }, } -impl ConfigLocation { +impl ConfigRequest { fn resolve_with( self, invocation_cwd: &Path, user_root: UserRoot, mut present: Present, - ) -> Result + ) -> Result where UserRoot: FnOnce() -> Option, Present: FnMut(&Path) -> io::Result, { match self { - Self::Path(path) => Ok(path), + Self::Source(source) => Ok(source), Self::Discover => { let local = invocation_cwd.join(DEFAULT_CONFIG_FILENAME); @@ -119,7 +191,7 @@ impl ConfigLocation { path: local.clone(), source, })? { - Ok(local) + Ok(ConfigSource::Path(local)) } else { let user = user_root() .ok_or(ConfigDiscoveryError::UserDirectoryUnavailable)? @@ -129,7 +201,7 @@ impl ConfigLocation { path: user.clone(), source, })? { - Ok(user) + Ok(ConfigSource::Path(user)) } else { Err(ConfigDiscoveryError::NotFound { local, user }) } @@ -139,11 +211,23 @@ impl ConfigLocation { } } -pub fn load_config(location: ConfigLocation) -> Result { +pub(crate) fn load_config(request: ConfigRequest) -> Result { let invocation_cwd = env::current_dir().map_err(|source| ConfigLoadError::CurrentDirectory { source })?; - let path = location.resolve(&invocation_cwd)?; - let path = absolute_path(&path, &invocation_cwd); + match request.resolve(&invocation_cwd)? { + ConfigSource::Path(path) => load_local(&path, &invocation_cwd), + ConfigSource::Https(url) => { + let source = https::fetch(&url).map_err(|source| ConfigLoadError::AcquireHttps { + url: url.clone(), + source, + })?; + load_remote(&url, &source, &invocation_cwd) + } + } +} + +fn load_local(path: &Path, invocation_cwd: &Path) -> Result { + let path = absolute_path(path, invocation_cwd); let real_path = fs::canonicalize(&path).map_err(|source| ConfigLoadError::Canonicalize { path: path.clone(), source, @@ -165,14 +249,44 @@ pub fn load_config(location: ConfigLocation) -> Result Result { + let config = Config::parse(source).map_err(|error| match error { + ConfigParseError::Deserialize { source } => ConfigLoadError::RemoteParse { + url: url.clone(), + source, + }, + ConfigParseError::Validation { source } => ConfigLoadError::RemoteValidation { + url: url.clone(), + source, + }, + })?; - ConfigFile::new(config, config_dir, real_config_dir, invocation_cwd) - .map_err(ConfigLoadError::from) + ConfigFile::new( + config, + invocation_cwd.to_path_buf(), + invocation_cwd.to_path_buf(), + invocation_cwd.to_path_buf(), + ) + .map_err(ConfigLoadError::from) } fn absolute_path(path: &Path, invocation_cwd: &Path) -> PathBuf { @@ -184,7 +298,7 @@ fn absolute_path(path: &Path, invocation_cwd: &Path) -> PathBuf { } #[derive(Debug, thiserror::Error)] -pub enum ConfigLoadError { +pub(crate) enum ConfigLoadError { #[error(transparent)] Discovery(#[from] ConfigDiscoveryError), @@ -196,6 +310,12 @@ pub enum ConfigLoadError { #[source] source: io::Error, }, + #[error("failed to acquire configuration from `{url}`: {source}")] + AcquireHttps { + url: Url, + #[source] + source: HttpsError, + }, #[error( "failed to canonicalize configuration `{}`: {source}", .path.display() @@ -226,6 +346,18 @@ pub enum ConfigLoadError { #[source] source: ConfigValidationError, }, + #[error("failed to parse configuration from `{url}`: {source}")] + RemoteParse { + url: Url, + #[source] + source: toml::de::Error, + }, + #[error("failed to validate configuration from `{url}`: {source}")] + RemoteValidation { + url: Url, + #[source] + source: ConfigValidationError, + }, } #[cfg(test)] @@ -238,6 +370,69 @@ mod tests { use super::*; + #[test] + fn classifies_config_sources_by_explicit_scheme() { + enum Expected<'a> { + Path(&'a str), + Https(&'a str), + Unsupported(&'a str), + InvalidHttps, + } + + let cases = [ + ("config/dot.toml", Expected::Path("config/dot.toml")), + ("/etc/dot/dot.toml", Expected::Path("/etc/dot/dot.toml")), + ( + r"C:\Users\alice\dot.toml", + Expected::Path(r"C:\Users\alice\dot.toml"), + ), + ( + "C:/Users/alice/dot.toml", + Expected::Path("C:/Users/alice/dot.toml"), + ), + ( + "C://Users/alice/.dot.toml", + Expected::Path("C://Users/alice/.dot.toml"), + ), + ( + "HTTPS://example.com/dot.toml", + Expected::Https("https://example.com/dot.toml"), + ), + ("http://example.com/dot.toml", Expected::Unsupported("http")), + ("file:///etc/dot.toml", Expected::Unsupported("file")), + ("https://[::1", Expected::InvalidHttps), + ]; + + for (input, expected) in cases { + let actual = input.parse::(); + match expected { + Expected::Path(path) => assert_eq!( + actual.expect("source should be a path"), + ConfigSource::Path(PathBuf::from(path)), + "input: {input}" + ), + Expected::Https(url) => assert_eq!( + actual.expect("source should be HTTPS"), + ConfigSource::Https(Url::parse(url).expect("expected URL should parse")), + "input: {input}" + ), + Expected::Unsupported(scheme) => assert!( + matches!( + actual, + Err(ConfigSourceError::UnsupportedScheme { + scheme: actual_scheme, + }) if actual_scheme == scheme + ), + "input: {input}" + ), + Expected::InvalidHttps => assert!( + matches!(actual, Err(ConfigSourceError::InvalidHttpsUrl { .. })), + "input: {input}" + ), + } + } + } + fn fixture_path(relative: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -246,6 +441,58 @@ mod tests { .join(relative) } + #[test] + fn remote_parse_and_validation_errors_name_the_source_url() { + let url = + Url::parse("https://example.com/config/dot.toml").expect("test URL should be valid"); + let cwd = env::current_dir().expect("test should have a current directory"); + + let parse = load_remote(&url, "[targets", &cwd) + .expect_err("invalid TOML should fail remote loading"); + assert!(matches!( + &parse, + ConfigLoadError::RemoteParse { url: actual, .. } if actual == &url + )); + assert!(parse.to_string().contains(url.as_str())); + + let validation = load_remote( + &url, + r#"[targets.machine] +platform = { os = "linux" } + +[targets.machine.profiles.desktop.profiles.shared] +[targets.machine.profiles.server.profiles.shared] +"#, + &cwd, + ) + .expect_err("invalid configuration should fail remote loading"); + assert!(matches!( + &validation, + ConfigLoadError::RemoteValidation { url: actual, .. } if actual == &url + )); + assert!(validation.to_string().contains(url.as_str())); + } + + #[test] + fn remote_loading_uses_the_absolute_invocation_directory_for_all_context() { + let url = + Url::parse("https://example.com/config/dot.toml").expect("test URL should be valid"); + let cwd = env::current_dir().expect("test should have a current directory"); + let loaded = load_remote( + &url, + r#"[targets.machine] +platform = { os = ["linux", "macos", "windows"] } +"#, + &cwd, + ) + .expect("valid remote configuration should load"); + + assert!(cwd.is_absolute()); + assert_eq!(loaded.config_dir(), cwd); + assert_eq!(loaded.real_config_dir(), cwd); + assert_eq!(loaded.cwd(), cwd); + } + fn unique_temp_path(label: &str) -> PathBuf { let nonce = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -254,11 +501,15 @@ mod tests { env::temp_dir().join(format!("dot-{label}-{}-{nonce}", std::process::id())) } + fn path_request(path: PathBuf) -> ConfigRequest { + ConfigRequest::Source(ConfigSource::Path(path)) + } + #[test] fn explicit_request_bypasses_discovery() { let requested = PathBuf::from("relative/config.toml"); - let resolved = ConfigLocation::Path(requested.clone()) + let resolved = path_request(requested.clone()) .resolve_with( Path::new("/unused"), || panic!("user root must not be queried"), @@ -266,18 +517,18 @@ mod tests { ) .expect("explicit request should resolve"); - assert_eq!(resolved, requested); + assert_eq!(resolved, ConfigSource::Path(requested)); } #[test] fn explicit_resolve_preserves_the_requested_path() { let requested = PathBuf::from("relative/config.toml"); - let resolved = ConfigLocation::Path(requested.clone()) + let resolved = path_request(requested.clone()) .resolve(Path::new("/unused")) .expect("explicit request should resolve"); - assert_eq!(resolved, requested); + assert_eq!(resolved, ConfigSource::Path(requested)); } #[test] @@ -285,7 +536,7 @@ mod tests { let invocation_cwd = PathBuf::from("/work"); let expected = invocation_cwd.join(".dot.toml"); - let resolved = ConfigLocation::Discover + let resolved = ConfigRequest::Discover .resolve_with( &invocation_cwd, || panic!("user root must not be queried"), @@ -296,7 +547,7 @@ mod tests { ) .expect("local candidate should resolve"); - assert_eq!(resolved, expected); + assert_eq!(resolved, ConfigSource::Path(expected)); } #[test] @@ -325,7 +576,7 @@ mod tests { let user = home.join(".config").join("dot").join(".dot.toml"); let mut inspected = Vec::new(); - let resolved = ConfigLocation::Discover + let resolved = ConfigRequest::Discover .resolve_with( &invocation_cwd, || Some(UserConfigRoot::Home(home)), @@ -336,7 +587,7 @@ mod tests { ) .expect("user candidate should resolve"); - assert_eq!(resolved, user); + assert_eq!(resolved, ConfigSource::Path(user.clone())); assert_eq!(inspected, vec![local, user]); } @@ -345,7 +596,7 @@ mod tests { let invocation_cwd = PathBuf::from("/work"); let local = invocation_cwd.join(".dot.toml"); - let error = ConfigLocation::Discover + let error = ConfigRequest::Discover .resolve_with( &invocation_cwd, || panic!("user root must not be queried"), @@ -373,7 +624,7 @@ mod tests { let local = invocation_cwd.join(".dot.toml"); let mut inspected = Vec::new(); - let error = ConfigLocation::Discover + let error = ConfigRequest::Discover .resolve_with( &invocation_cwd, || None, @@ -398,7 +649,7 @@ mod tests { let home = PathBuf::from("/home/alice"); let user = home.join(".config").join("dot").join(".dot.toml"); - let error = ConfigLocation::Discover + let error = ConfigRequest::Discover .resolve_with( &invocation_cwd, || Some(UserConfigRoot::Home(home)), @@ -419,12 +670,12 @@ mod tests { assert_eq!( error.to_string(), format!( - "configuration not found; checked `{}` then `{}`; use --config PATH to select another file", + "configuration not found; checked `{}` then `{}`; use --config SOURCE to select another file", local.display(), user.display() ) ); - assert!(error.to_string().contains("--config PATH")); + assert!(error.to_string().contains("--config SOURCE")); } #[test] @@ -489,7 +740,7 @@ mod tests { .expect("dangling symlink should be created"); let present = path_entry_exists(&candidate).expect("symlink entry should be inspectable"); - let load_error = load_config(ConfigLocation::Path(candidate.clone())) + let load_error = load_config(path_request(candidate.clone())) .expect_err("dangling symlink should not load"); fs::remove_file(&candidate).expect("temporary symlink should be removed"); @@ -516,7 +767,7 @@ mod tests { std::os::unix::fs::symlink(&entity, &entry) .expect("configuration symlink should be created"); - let result = load_config(ConfigLocation::Path(entry.clone())); + let result = load_config(path_request(entry.clone())); fs::remove_file(&entry).expect("temporary symlink should be removed"); fs::remove_dir(&entity).expect("directory entity should be removed"); @@ -546,8 +797,7 @@ mod tests { let expected_real_path = fs::canonicalize(&expected_path).expect("fixture path should canonicalize"); - let loaded = - load_config(ConfigLocation::Path(expected_path.clone())).expect("fixture should load"); + let loaded = load_config(path_request(expected_path.clone())).expect("fixture should load"); assert_eq!(loaded.config().targets.len(), 6); assert_eq!(loaded.config_dir(), expected_path.parent().unwrap()); @@ -570,7 +820,7 @@ mod tests { let real = fs::canonicalize(fixture_path("dot.toml")).expect("fixture should canonicalize"); std::os::unix::fs::symlink(&real, &entry).expect("configuration symlink should be created"); - let result = load_config(ConfigLocation::Path(entry.clone())); + let result = load_config(path_request(entry.clone())); fs::remove_file(&entry).expect("temporary symlink should be removed"); fs::remove_dir(&directory).expect("temporary directory should be removed"); @@ -613,7 +863,7 @@ platform = { os = ["linux", "macos", "windows"] } ) .expect("test manifest should be written"); - let result = load_config(ConfigLocation::Path(relative_path)); + let result = load_config(path_request(relative_path)); let real_dir = fs::canonicalize(&absolute_dir).expect("temporary directory should canonicalize"); fs::remove_dir_all(&absolute_dir).expect("temporary directory should be removed"); @@ -628,8 +878,8 @@ platform = { os = ["linux", "macos", "windows"] } fn missing_manifest_reports_the_requested_absolute_entry_path() { let missing = unique_temp_path("missing-manifest"); - let error = load_config(ConfigLocation::Path(missing.clone())) - .expect_err("missing manifest should fail"); + let error = + load_config(path_request(missing.clone())).expect_err("missing manifest should fail"); match &error { ConfigLoadError::Canonicalize { path, source } => { @@ -649,7 +899,7 @@ platform = { os = ["linux", "macos", "windows"] } #[test] fn invalid_documents_and_manifests_report_the_requested_absolute_path() { let invalid_document = fixture_path("config/invalid-syntax.toml"); - let parse_error = load_config(ConfigLocation::Path(invalid_document.clone())) + let parse_error = load_config(path_request(invalid_document.clone())) .expect_err("invalid TOML should fail"); assert!(matches!( parse_error, @@ -657,7 +907,7 @@ platform = { os = ["linux", "macos", "windows"] } )); let invalid_manifest = fixture_path("manifest/invalid-duplicate-profile-name.toml"); - let validation_error = load_config(ConfigLocation::Path(invalid_manifest.clone())) + let validation_error = load_config(path_request(invalid_manifest.clone())) .expect_err("invalid manifest should fail"); assert!(matches!( validation_error, diff --git a/dot-cli/src/config/https.rs b/dot-cli/src/config/https.rs new file mode 100644 index 0000000..29bb1af --- /dev/null +++ b/dot-cli/src/config/https.rs @@ -0,0 +1,194 @@ +use std::io::{self, Read}; +use std::string::FromUtf8Error; + +use url::Url; + +const MAX_REDIRECTS: u32 = 10; + +pub(crate) fn fetch(url: &Url) -> Result { + let agent = build_agent(); + let mut response = agent + .get(url.as_str()) + .call() + .map_err(HttpsError::from_call)?; + validate_final_status(response.status().as_u16())?; + read_body(response.body_mut().as_reader()) +} + +fn build_agent() -> ureq::Agent { + ureq::Agent::config_builder() + .https_only(true) + .max_redirects(MAX_REDIRECTS) + .max_redirects_will_error(true) + .http_status_as_error(true) + .build() + .into() +} + +fn validate_final_status(status: u16) -> Result<(), HttpsError> { + if (200..=299).contains(&status) { + Ok(()) + } else { + Err(HttpsError::StatusCode { + status, + source: None, + }) + } +} + +fn read_body(mut reader: impl Read) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|source| HttpsError::BodyRead { source })?; + String::from_utf8(bytes).map_err(|source| HttpsError::InvalidUtf8 { source }) +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum HttpsError { + #[error("HTTPS-only policy rejected the request or an insecure redirect: {source}")] + RequireHttpsOnly { + #[source] + source: ureq::Error, + }, + #[error("HTTPS redirect limit of {MAX_REDIRECTS} was exhausted: {source}")] + TooManyRedirects { + #[source] + source: ureq::Error, + }, + #[error("HTTP response status {status} is not successful")] + StatusCode { + status: u16, + #[source] + source: Option, + }, + #[error("HTTPS transport failed: {source}")] + TransportIo { + #[source] + source: io::Error, + }, + #[error("HTTPS transport failed: {source}")] + Transport { + #[source] + source: ureq::Error, + }, + #[error("failed to read the HTTPS response body: {source}")] + BodyRead { + #[source] + source: io::Error, + }, + #[error("HTTPS response body is not valid UTF-8: {source}")] + InvalidUtf8 { + #[source] + source: FromUtf8Error, + }, +} + +impl HttpsError { + fn from_call(source: ureq::Error) -> Self { + match source { + source @ ureq::Error::RequireHttpsOnly(_) => Self::RequireHttpsOnly { source }, + source @ ureq::Error::TooManyRedirects => Self::TooManyRedirects { source }, + source @ ureq::Error::StatusCode(status) => Self::StatusCode { + status, + source: Some(source), + }, + ureq::Error::Io(source) => Self::TransportIo { source }, + source => Self::Transport { source }, + } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::io::{self, Cursor, Read}; + + use super::*; + + #[test] + fn agent_enforces_https_redirect_and_status_policy() { + let agent = build_agent(); + let config = agent.config(); + + assert!(config.https_only()); + assert_eq!(config.max_redirects(), 10); + assert!(config.max_redirects_will_error()); + assert!(config.http_status_as_error()); + } + + #[test] + fn maps_each_ureq_call_error_category() { + let require_https = HttpsError::from_call(ureq::Error::RequireHttpsOnly( + "http://example.com/dot.toml".to_owned(), + )); + assert!(matches!(require_https, HttpsError::RequireHttpsOnly { .. })); + assert!(Error::source(&require_https).is_some_and(|source| source.is::())); + + let redirects = HttpsError::from_call(ureq::Error::TooManyRedirects); + assert!(matches!(redirects, HttpsError::TooManyRedirects { .. })); + + let status = HttpsError::from_call(ureq::Error::StatusCode(503)); + assert!(matches!( + status, + HttpsError::StatusCode { + status: 503, + source: Some(_), + } + )); + + let transport = HttpsError::from_call(ureq::Error::ConnectionFailed); + assert!(matches!(transport, HttpsError::Transport { .. })); + assert!(Error::source(&transport).is_some_and(|source| source.is::())); + } + + #[test] + fn maps_ureq_io_to_the_underlying_typed_transport_source() { + let error = HttpsError::from_call(ureq::Error::Io(io::Error::new( + io::ErrorKind::ConnectionAborted, + "network stopped", + ))); + + let source = Error::source(&error) + .and_then(|source| source.downcast_ref::()) + .expect("transport I/O should be the immediate typed source"); + assert_eq!(source.kind(), io::ErrorKind::ConnectionAborted); + } + + #[test] + fn final_status_accepts_only_success_responses() { + for status in [200, 204, 299] { + validate_final_status(status).expect("2xx status should succeed"); + } + for status in [300, 302, 399] { + assert!(matches!( + validate_final_status(status), + Err(HttpsError::StatusCode { + status: actual, + source: None, + }) if actual == status + )); + } + } + + #[test] + fn body_decoding_preserves_io_and_utf8_failures() { + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("response body stopped")) + } + } + + let read = read_body(FailingReader).expect_err("body I/O should fail"); + assert!(matches!(read, HttpsError::BodyRead { .. })); + assert!(Error::source(&read).is_some_and(|source| source.is::())); + + let utf8 = read_body(Cursor::new([0xff])).expect_err("invalid UTF-8 should fail"); + assert!(matches!(utf8, HttpsError::InvalidUtf8 { .. })); + assert!( + Error::source(&utf8).is_some_and(|source| source.is::()) + ); + } +} diff --git a/dot-cli/src/main.rs b/dot-cli/src/main.rs index 50fba4a..a4835f1 100644 --- a/dot-cli/src/main.rs +++ b/dot-cli/src/main.rs @@ -1,6 +1,5 @@ use std::collections::BTreeSet; use std::io::{self, IsTerminal, Write}; -use std::path::PathBuf; use std::process::ExitCode; use clap::error::ErrorKind; @@ -15,7 +14,7 @@ use dot_core::report::{CommandReport, ReportStatus}; use dot_core::schema::SelectorIdentifier; use dot_core::selection::{ExecutionSelection, ProfileSelection, ScopeSelection}; -use config::{ConfigLocation, load_config}; +use config::{ConfigRequest, ConfigSource, load_config}; mod config; #[cfg(feature = "dev-platform-override")] @@ -30,9 +29,9 @@ mod platform_override; subcommand_required = true )] struct Cli { - /// Path to the TOML manifest; defaults to ./.dot.toml, then the user fallback - #[arg(short, long, global = true, value_name = "PATH")] - config: Option, + /// Path or HTTPS URL to the TOML manifest; defaults to ./.dot.toml, then the user fallback + #[arg(short, long, global = true, value_name = "SOURCE")] + config: Option, /// Inject PlatformInfo for development-time compatibility selection; host environment, XDG /// paths, commands, and filesystem state remain unchanged @@ -60,10 +59,10 @@ impl Cli { #[cfg(not(feature = "dev-platform-override"))] let platform_override: Option = None; - let location = self + let request = self .config - .map_or(ConfigLocation::Discover, ConfigLocation::Path); - let config = match load_config(location) { + .map_or(ConfigRequest::Discover, ConfigRequest::Source); + let config = match load_config(request) { Ok(config) => config, Err(error) => return command_error(error), }; diff --git a/dot-cli/tests/cli.rs b/dot-cli/tests/cli.rs index c373d53..fe3dc78 100644 --- a/dot-cli/tests/cli.rs +++ b/dot-cli/tests/cli.rs @@ -99,7 +99,16 @@ fn exposes_standard_help_version_and_config_discovery_documentation() { assert!(help.status.success(), "{stdout}"); assert!(version.status.success()); - assert!(normalized.contains("--config "), "{stdout}"); + assert!(normalized.contains("--config "), "{stdout}"); assert!(stdout.contains("./.dot.toml"), "{stdout}"); assert!(normalized.contains("user fallback"), "{stdout}"); } + +#[test] +fn rejects_unsupported_config_source_protocols_during_cli_parsing() { + let output = run(&["--config", "http://example.com/dot.toml", "list", "targets"]); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(2), "{stderr}"); + assert!(stderr.contains("protocol `http`"), "{stderr}"); +} From 3019d36bef6a841bb2aa7904286ef216ab0eaa57 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Thu, 6 Aug 2026 21:47:31 +0800 Subject: [PATCH 2/6] feat: load configuration from git worktrees --- dot-cli/src/config.rs | 30 +++ dot-cli/src/config/git.rs | 358 +++++++++++++++++++++++++ dot-cli/src/main.rs | 27 +- dot-cli/tests/cli.rs | 35 +++ dot-cli/tests/config_source_command.rs | 173 ++++++++++++ 5 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 dot-cli/src/config/git.rs create mode 100644 dot-cli/tests/config_source_command.rs diff --git a/dot-cli/src/config.rs b/dot-cli/src/config.rs index d573766..f8154b3 100644 --- a/dot-cli/src/config.rs +++ b/dot-cli/src/config.rs @@ -19,8 +19,10 @@ use dot_core::schema::Config; use dot_core::validation::ConfigValidationError; use dot_core::{ConfigFile, ConfigFileError}; +mod git; mod https; +pub(crate) use git::GitError; pub(crate) use https::HttpsError; const DEFAULT_CONFIG_FILENAME: &str = ".dot.toml"; @@ -121,6 +123,10 @@ fn detect_user_config_root() -> Option { pub(crate) enum ConfigRequest { Discover, Source(ConfigSource), + Git { + repository: String, + worktree: PathBuf, + }, } impl ConfigRequest { @@ -184,6 +190,9 @@ impl ConfigRequest { { match self { Self::Source(source) => Ok(source), + Self::Git { worktree, .. } => Ok(ConfigSource::Path( + absolute_path(&worktree, invocation_cwd).join(DEFAULT_CONFIG_FILENAME), + )), Self::Discover => { let local = invocation_cwd.join(DEFAULT_CONFIG_FILENAME); @@ -214,6 +223,18 @@ impl ConfigRequest { pub(crate) fn load_config(request: ConfigRequest) -> Result { let invocation_cwd = env::current_dir().map_err(|source| ConfigLoadError::CurrentDirectory { source })?; + if let ConfigRequest::Git { + repository, + worktree, + } = request + { + let worktree = absolute_path(&worktree, &invocation_cwd); + git::prepare(&repository, &worktree).map_err(|source| ConfigLoadError::AcquireGit { + worktree: worktree.clone(), + source, + })?; + return load_local(&worktree.join(DEFAULT_CONFIG_FILENAME), &invocation_cwd); + } match request.resolve(&invocation_cwd)? { ConfigSource::Path(path) => load_local(&path, &invocation_cwd), ConfigSource::Https(url) => { @@ -316,6 +337,15 @@ pub(crate) enum ConfigLoadError { #[source] source: HttpsError, }, + #[error( + "failed to prepare Git configuration worktree `{}`: {source}", + .worktree.display() + )] + AcquireGit { + worktree: PathBuf, + #[source] + source: GitError, + }, #[error( "failed to canonicalize configuration `{}`: {source}", .path.display() diff --git a/dot-cli/src/config/git.rs b/dot-cli/src/config/git.rs new file mode 100644 index 0000000..5f3b3b6 --- /dev/null +++ b/dot-cli/src/config/git.rs @@ -0,0 +1,358 @@ +use std::fmt::Write as _; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Output}; + +const LOCATION_ENVIRONMENT: &[&str] = &[ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_SHALLOW_FILE", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_NAMESPACE", +]; + +pub(crate) fn prepare(repository: &str, worktree: &Path) -> Result<(), GitError> { + match fs::symlink_metadata(worktree) { + Ok(metadata) => validate_entry(repository, worktree, &metadata), + Err(source) if source.kind() == io::ErrorKind::NotFound => { + clone_repository(repository, worktree)?; + let metadata = fs::symlink_metadata(worktree).map_err(|source| GitError::Inspect { + worktree: worktree.to_path_buf(), + source, + })?; + validate_entry(repository, worktree, &metadata) + } + Err(source) => Err(GitError::Inspect { + worktree: worktree.to_path_buf(), + source, + }), + } +} + +fn clone_repository(repository: &str, worktree: &Path) -> Result<(), GitError> { + let status = git_command() + .arg("clone") + .arg("--origin") + .arg("origin") + .arg("--") + .arg(repository) + .arg(worktree) + .status() + .map_err(|source| GitError::CloneLaunch { + worktree: worktree.to_path_buf(), + source, + })?; + if status.success() { + Ok(()) + } else { + Err(GitError::CloneFailed { + worktree: worktree.to_path_buf(), + status, + }) + } +} + +fn validate_entry( + repository: &str, + worktree: &Path, + metadata: &fs::Metadata, +) -> Result<(), GitError> { + if !metadata.file_type().is_dir() { + return Err(GitError::NotDirectory { + worktree: worktree.to_path_buf(), + }); + } + + let inside = checked_output( + worktree, + "determine whether the entry is a Git worktree", + &["rev-parse", "--is-inside-work-tree"], + )?; + if strip_line_ending(&inside.stdout) != b"true" { + return Err(GitError::NotWorktree { + worktree: worktree.to_path_buf(), + actual: render_bytes(strip_line_ending(&inside.stdout)), + }); + } + + let prefix = checked_output( + worktree, + "determine the Git worktree root", + &["rev-parse", "--show-prefix"], + )?; + let prefix = strip_line_ending(&prefix.stdout); + if !prefix.is_empty() { + return Err(GitError::NotWorktreeRoot { + worktree: worktree.to_path_buf(), + prefix: render_bytes(prefix), + }); + } + + validate_origin(repository, worktree) +} + +fn validate_origin(repository: &str, worktree: &Path) -> Result<(), GitError> { + let output = git_output( + worktree, + "read remote.origin.url", + &[ + "config", + "--local", + "--null", + "--get-all", + "remote.origin.url", + ], + )?; + if !output.status.success() { + if output.status.code() == Some(1) && output.stdout.is_empty() { + return Err(GitError::MissingOrigin { + worktree: worktree.to_path_buf(), + }); + } + return Err(command_failed(worktree, "read remote.origin.url", output)); + } + + let origin = parse_origin(&output.stdout).map_err(|error| match error { + OriginOutputError::Missing => GitError::MissingOrigin { + worktree: worktree.to_path_buf(), + }, + OriginOutputError::Repeated => GitError::RepeatedOrigin { + worktree: worktree.to_path_buf(), + }, + OriginOutputError::Malformed => GitError::MalformedOrigin { + worktree: worktree.to_path_buf(), + }, + })?; + + if origin != repository.as_bytes() { + // Git may store a relative local repository as an absolute origin. This + // comparison is intentionally literal; dot does not normalize Git sources. + return Err(GitError::OriginMismatch { + worktree: worktree.to_path_buf(), + actual: render_bytes(origin), + expected: render_bytes(repository.as_bytes()), + }); + } + Ok(()) +} + +fn checked_output( + worktree: &Path, + operation: &'static str, + args: &[&str], +) -> Result { + let output = git_output(worktree, operation, args)?; + if output.status.success() { + Ok(output) + } else { + Err(command_failed(worktree, operation, output)) + } +} + +fn git_output(worktree: &Path, operation: &'static str, args: &[&str]) -> Result { + git_command() + .arg("-C") + .arg(worktree) + .args(args) + .output() + .map_err(|source| GitError::CommandLaunch { + operation, + worktree: worktree.to_path_buf(), + source, + }) +} + +fn command_failed(worktree: &Path, operation: &'static str, output: Output) -> GitError { + GitError::CommandFailed { + operation, + worktree: worktree.to_path_buf(), + status: output.status, + stderr: render_bytes(&output.stderr), + } +} + +fn git_command() -> Command { + let mut command = Command::new("git"); + for variable in LOCATION_ENVIRONMENT { + command.env_remove(variable); + } + command +} + +fn strip_line_ending(bytes: &[u8]) -> &[u8] { + let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes); + bytes.strip_suffix(b"\r").unwrap_or(bytes) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OriginOutputError { + Missing, + Repeated, + Malformed, +} + +fn parse_origin(stdout: &[u8]) -> Result<&[u8], OriginOutputError> { + if stdout.is_empty() { + return Err(OriginOutputError::Missing); + } + let Some(origin) = stdout.strip_suffix(b"\0") else { + return Err(OriginOutputError::Malformed); + }; + if origin.contains(&b'\0') { + return Err(OriginOutputError::Repeated); + } + Ok(origin) +} + +fn render_bytes(bytes: &[u8]) -> String { + let mut rendered = String::new(); + for byte in bytes { + match byte { + b'`' => rendered.push_str(r"\`"), + b'\\' => rendered.push_str(r"\\"), + b'\n' => rendered.push_str(r"\n"), + b'\r' => rendered.push_str(r"\r"), + b'\t' => rendered.push_str(r"\t"), + b' '..=b'~' => rendered.push(char::from(*byte)), + byte => write!(rendered, "\\x{byte:02x}").expect("writing to a String cannot fail"), + } + } + rendered +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum GitError { + #[error("failed to inspect Git worktree entry `{}`: {source}", .worktree.display())] + Inspect { + worktree: PathBuf, + #[source] + source: io::Error, + }, + #[error("Git worktree entry `{}` is not a directory", .worktree.display())] + NotDirectory { worktree: PathBuf }, + #[error("failed to launch Git clone for worktree `{}`: {source}", .worktree.display())] + CloneLaunch { + worktree: PathBuf, + #[source] + source: io::Error, + }, + #[error("Git clone for worktree `{}` exited with {status}", .worktree.display())] + CloneFailed { + worktree: PathBuf, + status: ExitStatus, + }, + #[error("failed to launch Git to {operation} in `{}`: {source}", .worktree.display())] + CommandLaunch { + operation: &'static str, + worktree: PathBuf, + #[source] + source: io::Error, + }, + #[error( + "Git failed to {operation} in `{}` with {status}; stderr: `{stderr}`", + .worktree.display() + )] + CommandFailed { + operation: &'static str, + worktree: PathBuf, + status: ExitStatus, + stderr: String, + }, + #[error( + "Git entry `{}` is not a worktree (`rev-parse --is-inside-work-tree` returned `{actual}`)", + .worktree.display() + )] + NotWorktree { worktree: PathBuf, actual: String }, + #[error( + "Git entry `{}` is not the worktree root (`rev-parse --show-prefix` returned `{prefix}`)", + .worktree.display() + )] + NotWorktreeRoot { worktree: PathBuf, prefix: String }, + #[error("Git worktree `{}` has no remote.origin.url", .worktree.display())] + MissingOrigin { worktree: PathBuf }, + #[error("Git worktree `{}` has more than one remote.origin.url", .worktree.display())] + RepeatedOrigin { worktree: PathBuf }, + #[error( + "Git worktree `{}` returned a malformed remote.origin.url record", + .worktree.display() + )] + MalformedOrigin { worktree: PathBuf }, + #[error( + "Git worktree `{}` origin does not match --git\nactual: `{actual}`\nexpected: `{expected}`", + .worktree.display() + )] + OriginMismatch { + worktree: PathBuf, + actual: String, + expected: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_exactly_one_nul_terminated_origin_record() { + enum Expected<'a> { + Origin(&'a [u8]), + Missing, + Repeated, + Malformed, + } + + let cases = [ + (&b""[..], Expected::Missing), + (&b"first\0second\0"[..], Expected::Repeated), + (&b"missing terminator"[..], Expected::Malformed), + ( + &b"https://example.com/dot.git\0"[..], + Expected::Origin(b"https://example.com/dot.git"), + ), + (&b"\xff\0"[..], Expected::Origin(b"\xff")), + ]; + + for (stdout, expected) in cases { + let actual = parse_origin(stdout); + match expected { + Expected::Origin(origin) => assert_eq!( + actual.expect("one terminated record should parse"), + origin, + "stdout: {stdout:?}" + ), + Expected::Missing => assert!( + matches!(actual, Err(OriginOutputError::Missing)), + "stdout: {stdout:?}" + ), + Expected::Repeated => assert!( + matches!(actual, Err(OriginOutputError::Repeated)), + "stdout: {stdout:?}" + ), + Expected::Malformed => assert!( + matches!(actual, Err(OriginOutputError::Malformed)), + "stdout: {stdout:?}" + ), + } + } + } + + #[test] + fn renders_origin_bytes_losslessly_for_single_line_diagnostics() { + let cases = [ + (&b"`"[..], r"\`"), + (&b"\\"[..], r"\\"), + (&b"line\n\x01"[..], r"line\n\x01"), + ("é".as_bytes(), r"\xc3\xa9"), + ]; + + for (input, expected) in cases { + assert_eq!(render_bytes(input), expected, "input: {input:?}"); + } + } +} diff --git a/dot-cli/src/main.rs b/dot-cli/src/main.rs index a4835f1..ee55be8 100644 --- a/dot-cli/src/main.rs +++ b/dot-cli/src/main.rs @@ -1,5 +1,6 @@ use std::collections::BTreeSet; use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; use std::process::ExitCode; use clap::error::ErrorKind; @@ -33,6 +34,20 @@ struct Cli { #[arg(short, long, global = true, value_name = "SOURCE")] config: Option, + /// Git repository containing a root .dot.toml + #[arg( + long, + global = true, + value_name = "REPOSITORY", + requires = "git_worktree", + conflicts_with = "config" + )] + git: Option, + + /// Persistent worktree path for --git + #[arg(long, global = true, value_name = "PATH", requires = "git")] + git_worktree: Option, + /// Inject PlatformInfo for development-time compatibility selection; host environment, XDG /// paths, commands, and filesystem state remain unchanged #[cfg(feature = "dev-platform-override")] @@ -59,9 +74,15 @@ impl Cli { #[cfg(not(feature = "dev-platform-override"))] let platform_override: Option = None; - let request = self - .config - .map_or(ConfigRequest::Discover, ConfigRequest::Source); + let request = match (self.config, self.git, self.git_worktree) { + (Some(source), None, None) => ConfigRequest::Source(source), + (None, Some(repository), Some(worktree)) => ConfigRequest::Git { + repository, + worktree, + }, + (None, None, None) => ConfigRequest::Discover, + _ => unreachable!("clap validated the configuration source arguments"), + }; let config = match load_config(request) { Ok(config) => config, Err(error) => return command_error(error), diff --git a/dot-cli/tests/cli.rs b/dot-cli/tests/cli.rs index fe3dc78..fa5c8f6 100644 --- a/dot-cli/tests/cli.rs +++ b/dot-cli/tests/cli.rs @@ -112,3 +112,38 @@ fn rejects_unsupported_config_source_protocols_during_cli_parsing() { assert_eq!(output.status.code(), Some(2), "{stderr}"); assert!(stderr.contains("protocol `http`"), "{stderr}"); } + +#[test] +fn requires_a_complete_non_conflicting_git_source_group() { + for args in [ + &["--git", "file:///source.git", "list", "targets"][..], + &["--git-worktree", "checkout", "list", "targets"][..], + &[ + "--config", + "config.toml", + "--git", + "file:///source.git", + "--git-worktree", + "checkout", + "list", + "targets", + ][..], + ] { + let output = run(args); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(2), "args: {args:?}\n{stderr}"); + } +} + +#[test] +fn help_documents_git_source_values_without_reusing_target() { + let output = run(&["apply", "--help"]); + let stdout = String::from_utf8_lossy(&output.stdout); + let normalized = stdout.split_whitespace().collect::>().join(" "); + + assert!(output.status.success(), "{stdout}"); + assert!(normalized.contains("--git "), "{stdout}"); + assert!(normalized.contains("--git-worktree "), "{stdout}"); + assert!(normalized.contains("--target "), "{stdout}"); +} diff --git a/dot-cli/tests/config_source_command.rs b/dot-cli/tests/config_source_command.rs new file mode 100644 index 0000000..b908e47 --- /dev/null +++ b/dot-cli/tests/config_source_command.rs @@ -0,0 +1,173 @@ +//! End-to-end Git configuration source behavior. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{self, Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use url::Url; + +static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); + +const MANIFEST: &str = r#"[targets.from-git] +platform = { os = ["linux", "macos", "windows"] } +"#; + +struct TempRepositories { + root: PathBuf, + cwd: PathBuf, +} + +impl TempRepositories { + fn new() -> Self { + let sequence = NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed); + let root = env::temp_dir().join(format!( + "dot-config-source-command-{}-{sequence}", + process::id() + )); + let cwd = root.join("cwd"); + fs::create_dir_all(&cwd).expect("temporary working directory should be created"); + let root = fs::canonicalize(root).expect("temporary root should canonicalize"); + let cwd = root.join("cwd"); + Self { root, cwd } + } + + fn repository(&self, name: &str) -> String { + let source = self.root.join(name); + fs::create_dir(&source).expect("source repository directory should be created"); + git(&source, &["init"]); + git(&source, &["config", "user.name", "dot tests"]); + git( + &source, + &["config", "user.email", "dot-tests@example.invalid"], + ); + fs::write(source.join(".dot.toml"), MANIFEST) + .expect("repository manifest should be written"); + git(&source, &["add", ".dot.toml"]); + git(&source, &["commit", "-m", "add configuration"]); + + let source = fs::canonicalize(source).expect("source repository should canonicalize"); + Url::from_file_path(source) + .expect("canonical source path should convert to a file URL") + .into() + } + + fn dot(&self, repository: &str, worktree: &Path) -> Output { + self.dot_command(repository, worktree) + .output() + .expect("dot should start") + } + + fn dot_with_global_origin( + &self, + repository: &str, + worktree: &Path, + global_origin: &str, + ) -> Output { + let global_config = self.root.join("misleading-global.gitconfig"); + fs::write( + &global_config, + format!("[remote \"origin\"]\n\turl = {global_origin}\n"), + ) + .expect("misleading global Git configuration should be written"); + self.dot_command(repository, worktree) + .env("GIT_CONFIG_GLOBAL", global_config) + .output() + .expect("dot should start") + } + + fn dot_command(&self, repository: &str, worktree: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_dot")); + command + .args([ + "--git", + repository, + "--git-worktree", + worktree + .to_str() + .expect("test worktree path should be Unicode"), + "list", + "targets", + "--all", + ]) + .current_dir(&self.cwd) + .env("GIT_DIR", self.root.join("misleading-git-dir")) + .env("GIT_WORK_TREE", self.root.join("misleading-work-tree")); + command + } +} + +impl Drop for TempRepositories { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn git(current_dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(current_dir) + .output() + .expect("installed Git should start"); + assert!( + output.status.success(), + "git {args:?} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_lists_git_target(output: &Output) { + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("from-git\tcompatible"), "{stdout}"); +} + +#[test] +fn clones_then_reuses_a_git_worktree_with_git_location_environment_isolated() { + let repositories = TempRepositories::new(); + let repository = repositories.repository("source"); + let requested_worktree = Path::new("persistent-checkout"); + let absolute_worktree = repositories.cwd.join(requested_worktree); + + let first = repositories.dot(&repository, requested_worktree); + assert_lists_git_target(&first); + assert!(absolute_worktree.join(".git").exists()); + + let second = repositories.dot_with_global_origin( + &repository, + requested_worktree, + "file:///misleading-global", + ); + assert_lists_git_target(&second); +} + +#[test] +fn rejects_an_existing_worktree_whose_origin_differs_from_git() { + let repositories = TempRepositories::new(); + let actual = repositories.repository("actual"); + let expected = repositories.repository("expected"); + let worktree = Path::new("persistent-checkout"); + assert_lists_git_target(&repositories.dot(&actual, worktree)); + + let output = repositories.dot(&expected, worktree); + let stderr = String::from_utf8_lossy(&output.stderr); + let absolute_worktree = repositories.cwd.join(worktree); + + assert!(!output.status.success(), "{stderr}"); + assert!( + stderr.contains(absolute_worktree.to_string_lossy().as_ref()), + "{stderr}" + ); + assert!(stderr.contains(&format!("actual: `{actual}`")), "{stderr}"); + assert!( + stderr.contains(&format!("expected: `{expected}`")), + "{stderr}" + ); +} From eb09eee003dd8ea072d8bb5cde9583c5b532ba0e Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Thu, 6 Aug 2026 22:12:03 +0800 Subject: [PATCH 3/6] docs: define configuration source acquisition --- README.md | 54 +++++++++--- docs/CONFIGURATION.md | 165 ++++++++++++++++++++++++------------- docs/DESIGN.txt | 186 +++++++++++++++++++++++++----------------- 3 files changed, 264 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index b42af51..ae26895 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ actions, and symbolic links. The manifest remains a readable inventory of the environment it describes. The Rust workspace separates the reusable `dot-core` library from `dot-cli`, -the package that owns local configuration discovery and produces the `dot` -executable. +the package that produces the `dot` executable. > [yslib/dotfiles](https://github.com/yslib/dotfiles) is the complete > application example used to develop `dot`. It describes an Arch Linux @@ -83,34 +82,63 @@ Apply the environment: dot apply --target workstation ``` -Without `--config`, `dot` checks `./.dot.toml` first, then +Without an explicit source, `dot` checks `./.dot.toml` first, then `~/.config/dot/.dot.toml` on Linux and macOS or `%APPDATA%\dot\.dot.toml` on Windows. The first candidate whose filesystem entry exists is selected; load, parse, or validation errors for that path do -not fall through to another candidate. An explicit `--config PATH` bypasses -discovery and may use any filename. `--target` may be omitted when exactly one -configured target is compatible with the current platform. +not fall through to another candidate. An explicit local path bypasses +discovery and may use any filename. See the +[local filesystem details](docs/CONFIGURATION.md#local-filesystem). + +Load a manifest directly from HTTPS: + +```console +dot --config https://example.com/dot.toml dry-run --target workstation +``` + +Use the root `.dot.toml` from a persistent Git worktree: + +```console +dot --git https://example.com/dotfiles.git \ + --git-worktree .dot-worktree \ + dry-run --target workstation +``` + +`--config SOURCE` accepts a local path or HTTPS URL and bypasses discovery. Git +clones a missing worktree and reuses an existing matching worktree without +updating it. See the [HTTPS](docs/CONFIGURATION.md#https) and +[Git worktree](docs/CONFIGURATION.md#git-worktree) guarantees for the complete +acquisition contract. ## Command line -The command is always explicit: +The command is always explicit; its configuration source may be discovered or +selected with one of these forms: + +```text +dot [--config SOURCE] +dot --git REPOSITORY --git-worktree PATH +``` + +`--config` conflicts with `--git`; `--git` and `--git-worktree` must be supplied +together. `` is one of: ```text -dot [--config PATH] apply +apply [--target TARGET] [--profile PROFILE] [--job KIND:ID]... -dot [--config PATH] dry-run +dry-run [--target TARGET] [--profile PROFILE] [--job KIND:ID]... -dot [--config PATH] check providers +check providers [--target TARGET] [--profile PROFILE] -dot [--config PATH] list targets [--all] +list targets [--all] -dot [--config PATH] list profiles +list profiles [--target TARGET] -dot [--config PATH] list jobs +list jobs [--target TARGET] [--profile PROFILE] ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e47995c..284b348 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,33 +6,82 @@ configuration. This document is the human-facing explanation of that schema; boundaries. When they differ, update `SCHEMA.txt` first and then synchronize this reference. -## Configuration discovery +## Configuration source acquisition -`dot` chooses one configuration using this exact precedence: +`dot` acquires exactly one manifest. Acquisition does not import or merge other +manifests and does not change the TOML schema. -1. the path from an explicit `--config PATH`, when supplied; -2. `.dot.toml` in the current working directory; -3. `~/.config/dot/.dot.toml` on Linux and macOS, or +The CLI accepts these source forms before any command dispatch: + +```text +dot [--config SOURCE] +dot --git REPOSITORY --git-worktree PATH +``` + +`--config` accepts a local path or HTTPS URL and conflicts with `--git`. +`--git` and `--git-worktree` must be supplied together. Source acquisition and +complete parse and static validation happen before any list, check, dry-run, +or apply operation is dispatched. `--target TARGET` is separate: it selects a +configured target inside the one acquired manifest. + +### Local filesystem + +With no explicit source, `dot` uses this exact discovery order: + +1. `.dot.toml` in the absolute invocation working directory; +2. `~/.config/dot/.dot.toml` on Linux and macOS, or `%APPDATA%\dot\.dot.toml` on Windows. -An explicit path is selected as given, may have any filename, and bypasses the -remaining discovery candidates. Among the automatic candidates, the first -whose filesystem entry exists is chosen. Read, parse, or validation failures -for the chosen path are reported immediately; `dot` does not fall back to -another candidate. It does not search parent directories recursively, merge -configuration files, or recognize `dot.toml` as a legacy default. - -After selection, `dot` makes the selected path absolute without following -symbolic links. This is the **entry path**: when a symlink was selected, it is -the absolute symlink path. Loading then eagerly calls -`std::fs::canonicalize`, reads the resulting **canonical entity path**, and -retains the entry path's parent as `${dot:config_dir}` and the canonical path's -parent as `${dot:real_config_dir}`. The configuration protocol also retains the -captured invocation directory as `${dot:cwd}`; it retains neither file path. A -dangling or otherwise unresolvable entry therefore fails every command during -loading. Canonical directory spelling is inherited exactly from the host -Rust/OS filesystem API. Read, parse, and validation diagnostics continue to -identify the entry path. +The first candidate whose filesystem entry exists is selected. Read, parse, +or validation failures for that path do not fall through to the next +candidate. Discovery does not search parent directories recursively or +recognize `dot.toml` as a legacy default. An explicit local path bypasses +discovery, is selected as given, and may have any filename. + +For a discovered or explicit local path, `dot` makes the selected path +absolute without following symbolic links. This is the **entry path**: when a +symlink was selected, it is the absolute symlink path. Loading then eagerly +calls `std::fs::canonicalize`, reads the resulting **canonical entity path**, +and retains the entry path's parent as `${dot:config_dir}` and the canonical +path's parent as `${dot:real_config_dir}`. The configuration protocol also +retains the captured invocation directory as `${dot:cwd}`; it retains neither +file path. A dangling or otherwise unresolvable entry therefore fails every +command during loading. Canonical directory spelling is inherited exactly +from the host Rust/OS filesystem API. Read, parse, and validation diagnostics +continue to identify the entry path. + +### HTTPS + +An HTTPS source is fetched exactly once per invocation and retained only in +memory. Only HTTPS URLs are accepted; requests and redirects must remain +HTTPS, the redirect limit is 10, and only a final 2xx response succeeds. The +complete body must be strict UTF-8. There is no cache, temporary file, +conditional request, or retry. Because a remote manifest has no local entry or +canonical entity, `${dot:config_dir}`, `${dot:real_config_dir}`, and +`${dot:cwd}` all retain the absolute invocation directory. + +### Git worktree + +Git acquisition invokes external `git` directly with argv and never through a +shell. If `--git-worktree PATH` does not exist, `dot` runs a normal clone with +the remote name forced to `origin`; normal Git configuration, credential +helpers, and transport behavior remain available to that clone. If the +worktree exists, `dot` reuses it read-only after confirming that the entry is a +directory, is a Git worktree root, and has exactly one repository-local +`remote.origin.url` whose raw value exactly matches the supplied `REPOSITORY`. +Origin identity is read specifically from repository-local config, not an +overriding higher-level configuration source. + +The worktree path is made absolute against the invocation directory. Every +resulting worktree, including a new clone, undergoes the root and origin checks +before `dot` loads only its root `.dot.toml` using the same entry-path and +canonical-entity semantics as any local source. This is not a general Git +manager: `dot` does not fetch, pull, checkout, select a ref, set a clone depth, +inspect or repair dirty state, repair an invalid worktree, or clean up after +clone or validation failure. Git may rewrite a relative local repository +spelling when it stores `origin`; because validation is literal, that rewrite +can produce a mismatch. Mismatch diagnostics show both the actual stored value +and expected argument. ## Type index @@ -797,10 +846,10 @@ a native local-disk target. Source URL userinfo is rejected, and a target URL is not supported. Runtime resolution is selected-only and occurs during pure planning. An -absolute target stays absolute. A relative target is relative to the directory -containing the selected configuration entry, equivalent to the path context -represented by `${dot:config_dir}`. It is not relative to the canonical entity -directory unless the value explicitly uses `${dot:real_config_dir}`. +absolute target stays absolute. A relative target uses the path context +represented by `${dot:config_dir}`: the selected entry directory for a local +or Git source, or the invocation directory for HTTPS. It does not use +`${dot:real_config_dir}` unless the value explicitly names that resolver. Dry-run resolves the source and target, and its human table displays that resolved pair without network access or target inspection. On apply, every @@ -856,9 +905,9 @@ on_conflict = "replace-link" on_missing_parent = "create" ``` -This Unix/macOS example deliberately asks for a source located under the -canonical configuration entity's directory. On Windows, use a TOML literal -string and a backslash suffix: +For a local or Git manifest, this Unix/macOS example deliberately asks for a +source located under the canonical configuration entity's directory. On +Windows, use a TOML literal string and a backslash suffix: ```toml [targets.windows.links.editor] @@ -866,16 +915,18 @@ source = '${dot:real_config_dir}\home\AppData\Local\nvim' target = '${xdg:config_local}\nvim' ``` -`std::fs::canonicalize` on Windows may return a verbatim path beginning with -`\\?\`, and dot preserves it. Appending `/home/...` textually to that form -does not add Windows path separators; the literal-string example above both -uses backslashes and avoids TOML basic-string escape processing. +For a local or Git manifest, `std::fs::canonicalize` on Windows may return a +verbatim path beginning with `\\?\`, and dot preserves it. Appending +`/home/...` textually to that form does not add Windows path separators; the +literal-string example above both uses backslashes and avoids TOML basic-string +escape processing. An unqualified relative source such as `source = "home/editor"` is resolved -from the selected configuration **entry directory**, not from -`${dot:real_config_dir}`. Symlinked configurations therefore do not silently -change the relative-source base to their repository or entity directory. -Users who want that base must request it explicitly as above. Target must +from `${dot:config_dir}`. That is the selected entry directory for a local or +Git manifest and the absolute invocation directory for HTTPS. Symlinked local +or Git configurations therefore do not silently change the relative-source +base to their repository or canonical entity directory; users who want that +base must request `${dot:real_config_dir}` explicitly as above. Target must resolve to an absolute path. Apply requires source to exist as a regular file or directory and creates a native symbolic link. A matching link is satisfied. All effective link paths resolve before mutation, and duplicate resolved @@ -956,10 +1007,11 @@ but that presentation is not a scheduling rule or stable ordering interface. Validation and evaluation have four distinct boundaries: -Configuration entry selection, absolute-path construction, eager -canonicalization, and reading happen before these expression boundaries. -Consequently every command, including dry-run and structural list commands, -requires a resolvable selected configuration entry. +Configuration source acquisition and parsing happen before these expression +boundaries. Local and Git manifests additionally require absolute-path +construction, eager canonicalization, and reading; HTTPS manifests are fetched +into memory. Consequently every command, including dry-run and structural list +commands, requires a successfully acquired source. 1. **Parsing and deserialization** check TOML structure, field types, required fields, broad and selector identifier rules, environment-name rules, and @@ -988,10 +1040,11 @@ requires a resolvable selected configuration entry. 4. **Execution** probes providers, runs processes, transfers Fetch Content, and reconciles links only after planning succeeds. Dry-run stops before this boundary: it performs no Fetch Content network request or target inspection - and does not inspect or canonicalize link sources, although loading has - already canonicalized the selected configuration entry. Structural list - commands stop after complete validation and unresolved target/profile - selection; they do not evaluate runtime resolver values. + and does not inspect or canonicalize link sources. Source acquisition has + nevertheless already completed, including entry canonicalization for a + local or Git manifest. Structural list commands stop after complete + validation and unresolved target/profile selection; they do not evaluate + runtime resolver values. Omitted provider, package, link, action, and profile maps deserialize as empty maps. Omitted ExecAction `args` and EnvironmentPatch `variables` deserialize as @@ -1028,9 +1081,9 @@ package:provider_args -> list | Resolver form | Resolved value | | --- | --- | | `${env:NAME}` | `NAME` from the current effective child environment | -| `${dot:config_dir}` | parent directory of the selected entry path | -| `${dot:real_config_dir}` | string form of the canonical entity path's parent directory | -| `${dot:cwd}` | working directory from which dot was started | +| `${dot:config_dir}` | local/Git entry directory; absolute invocation directory for HTTPS | +| `${dot:real_config_dir}` | local/Git canonical entity directory; absolute invocation directory for HTTPS | +| `${dot:cwd}` | absolute invocation directory | | `${xdg:home}` | current user's home directory | | `${xdg:config}` | standard user configuration directory | | `${xdg:config_local}` | local/non-roaming configuration directory | @@ -1042,12 +1095,14 @@ package:provider_args -> list | `${xdg:executable}` | standard per-user executable directory, when defined | | `${xdg:documents}` | current user's Documents directory, when available | -The `dot` values describe the current invocation. Configuration loading always -computes the lexical entry directory and canonical entity directory before any -resolver evaluation. On Windows, the real directory can contain the verbatim -`\\?\` prefix returned by the host API. All three `dot` path calls share the -same string-valued availability shown below. Like every path-to-string -resolver, `dot` and `xdg` use Rust's +The `dot` values describe the current invocation. For a local or Git source, +configuration loading computes the lexical entry directory and canonical +entity directory before any resolver evaluation. HTTPS has no entry or +canonical entity; both configuration-directory values equal the absolute +invocation directory, as does `${dot:cwd}`. On Windows, a local or Git real +directory can contain the verbatim `\\?\` prefix returned by the host API. All +three `dot` path calls share the same string-valued availability shown below. +Like every path-to-string resolver, `dot` and `xdg` use Rust's `Path::to_str()` boundary without lossy replacement; resolution fails when a path is not Unicode-representable. The `xdg` vocabulary follows XDG directories on Linux and platform-standard equivalents on Windows and macOS. A missing diff --git a/docs/DESIGN.txt b/docs/DESIGN.txt index b24a6c1..59ab0f9 100644 --- a/docs/DESIGN.txt +++ b/docs/DESIGN.txt @@ -39,19 +39,29 @@ a download, artifact, or cache manager. Its finite TOML schema is not a general-purpose configuration or execution DSL. -### configuration entry and entity - -Configuration discovery or `--config` selects one filesystem entry. dot first -converts that selection to an absolute path without following symbolic links; -its parent becomes the lexical configuration directory exposed by -`${dot:config_dir}`. - -Loading then eagerly calls `std::fs::canonicalize` on the entry path before -reading the file. The canonical result's parent becomes the canonical -configuration directory exposed by `${dot:real_config_dir}`. A broken, -dangling, or otherwise unresolvable entry fails loading for every command. -Reading and subsequent parse or validation diagnostics continue to identify -the selected entry path. +### configuration acquisition and path context + +All source acquisition is owned by `dot-cli`. The CLI acquires, parses, and +statically validates one manifest, then constructs the unchanged +source-independent ConfigFile consumed by `dot-core`. The core has no local +discovery, HTTPS, or Git acquisition behavior. Source selection is not a +cross-file import mechanism, repository search, or schema feature. + +Without an explicit source, local discovery checks `.dot.toml` in the absolute +invocation directory, then `~/.config/dot/.dot.toml` on Linux and macOS or +`%APPDATA%\dot\.dot.toml` on Windows. The first filesystem entry that exists +is selected; later load, parse, or validation failure does not fall through. +An explicit local `--config SOURCE` bypasses discovery and may use any +filename. + +For either local form, dot first converts the entry path to an absolute path +without following symbolic links; its parent becomes the lexical configuration +directory exposed by `${dot:config_dir}`. Loading eagerly calls +`std::fs::canonicalize` on the entry path before reading the file. The canonical +result's parent becomes `${dot:real_config_dir}`. A broken, dangling, or +otherwise unresolvable entry fails loading for every command. Reading and +subsequent parse or validation diagnostics continue to identify the selected +entry path. The canonical configuration directory retains the spelling inherited from the path returned by Rust and the host operating system. In particular, Windows @@ -61,6 +71,31 @@ path-to-string resolver, a `dot` path resolver uses `Path::to_str()` without lossy replacement and fails at resolution time if that exact path is not Unicode-representable. +An HTTPS `--config SOURCE` is acquired exactly once per invocation and kept +only in memory. Only HTTPS is accepted as a URL scheme. Requests and redirects +remain HTTPS, no more than 10 redirects are allowed, the final status must be +2xx, and the body must be strict UTF-8. No cache, temporary file, conditional +request, or retry exists. A remote source supplies no entry or entity path, so +`${dot:config_dir}`, `${dot:real_config_dir}`, and `${dot:cwd}` are all the +absolute invocation directory. + +The paired `--git REPOSITORY --git-worktree PATH` form makes `PATH` absolute +against the invocation directory and uses external Git with direct argv. A +missing worktree is cloned with the remote name forced to `origin`; normal Git +configuration, credential helpers, and transport behavior remain available. +An existing worktree is reused read-only only after dot validates that its +entry is a directory, it is a worktree root, and exactly one raw +repository-local `remote.origin.url` equals `REPOSITORY`. Every resulting +worktree, including a new clone, undergoes those root and origin checks before +dot loads the root `.dot.toml` with the local entry/entity semantics above. + +Git acquisition is not a general Git manager: it does not fetch, pull, +checkout, select refs, choose clone depth, inspect dirty state, repair, or clean +up. Clone and validation failures leave any resulting state in place. Git may +rewrite a relative local repository spelling while recording `origin`; +literal validation can therefore fail, and the mismatch diagnostic shows +actual and expected values. + ### target @@ -402,11 +437,11 @@ is deliberately narrow: Source and target string expressions are evaluated only when their action is in the selected execution closure. Resolution is pure planning: it performs no network request and does not inspect the target. An absolute target remains -absolute. A relative target is joined to the directory containing the selected -configuration entry, which is the same path context represented by -`${dot:config_dir}`. It is not implicitly rebased to the canonical -configuration entity directory; `${dot:real_config_dir}` explicitly selects -that real directory. +absolute. A relative target uses the path context represented by +`${dot:config_dir}`: the selected entry directory for a local or Git source, +or the invocation directory for HTTPS. `${dot:real_config_dir}` explicitly +selects the canonical directory for a local or Git source and the same +invocation directory for HTTPS. Dry-run resolves the HTTPS source and local target, and its human table displays that resolved pair, but it performs no network request and no target @@ -464,9 +499,10 @@ Each effective link record is one native symbolic-link intent from source to target. Link ids are used for map replacement, diagnostics, and result reporting. They are not referenced by other declarations. -- A relative source is resolved against the selected configuration entry - directory (`${dot:config_dir}`). It is not implicitly rebased to the - canonical entity directory. An absolute source is used as declared. +- A relative source is resolved against `${dot:config_dir}`: the selected + entry directory for a local or Git source, or the invocation directory for + HTTPS. It is not implicitly rebased to `${dot:real_config_dir}`. An absolute + source is used as declared. - target must resolve to an absolute path. - During apply, source must exist and be a regular file or directory. - v0.1.0 creates native symbolic links only. It does not fall back to hard @@ -548,10 +584,11 @@ v0.1.0 statically defines only these resolver calls: - ${package:names}: the complete package-name list for the current provider install unit; - ${package:provider_args}: the provider argument list for that install unit; -- ${dot:config_dir}: parent directory of that entry path; -- ${dot:real_config_dir}: string form of that canonical entity path's parent - directory; -- ${dot:cwd}: working directory from which dot was started; +- ${dot:config_dir}: the local/Git entry directory, or the absolute invocation + directory for HTTPS; +- ${dot:real_config_dir}: the local/Git canonical entity directory, or the + absolute invocation directory for HTTPS; +- ${dot:cwd}: the absolute invocation directory; - ${xdg:home}: the current user's home directory; - ${xdg:config} and ${xdg:config_local}: the standard configuration directory and its local/non-roaming counterpart; @@ -562,11 +599,13 @@ v0.1.0 statically defines only these resolver calls: them; - ${xdg:documents}: the current user's Documents directory when available. -The `dot` resolver describes this invocation of dot. The `xdg` resolver is the -portable configuration vocabulary for standard user directories: it follows -XDG base and user directories on Linux and their platform-standard equivalents -on Windows and macOS. An `xdg` value that the current platform does not define -is an error when resolved; dot does not invent a fallback path. +The `dot` resolver describes this invocation of dot. HTTPS supplies no entry or +canonical entity; both configuration-directory values equal `${dot:cwd}` for +that source. The `xdg` resolver is the portable configuration vocabulary for +standard user directories: it follows XDG base and user directories on Linux +and their platform-standard equivalents on Windows and macOS. An `xdg` value +that the current platform does not define is an error when resolved; dot does +not invent a fallback path. Resolver availability is contextual: @@ -689,13 +728,13 @@ its complete names list. A selected Fetch Content Action resolves its semantic source and target locators during this same pure planning stage. Its currently supported HTTPS source is validated without making a request, and its local target is made absolute by retaining an absolute path or joining a relative -path to the configuration entry directory. +path to `${dot:config_dir}`. -Relative link sources are likewise joined to the configuration entry -directory, not the canonical entity directory, and link targets must resolve -to absolute paths. A repository-relative source or Fetch Content target behind -a symlinked configuration must explicitly use `${dot:real_config_dir}`. On Unix -and macOS, for example: +Relative link sources are likewise joined to `${dot:config_dir}`, not +`${dot:real_config_dir}`, and link targets must resolve to absolute paths. A +repository-relative source or Fetch Content target behind a symlinked local or +Git configuration must explicitly use `${dot:real_config_dir}`. On Unix and +macOS, for example: ``` source = "${dot:real_config_dir}/home/.config/nvim" @@ -713,12 +752,12 @@ Dry-run does not probe, ensure, or install providers; run manual-package or global Command Action checks and execs; transfer Fetch Content; inspect Fetch Content or link target state; or reconcile links. It does not test link-source existence, canonicalize link paths, inspect conflicts, or modify the -filesystem. Configuration loading has nevertheless already canonicalized the -selected configuration entry before dry-run planning begins. Its output -describes selected intent, not runtime outcome: a provider may be missing, a -package may already be installed, a command check may skip exec, a Fetch -Content target may conflict, and a link may already be satisfied or may fail -during apply. +filesystem. Configuration acquisition has nevertheless already completed, +including entry canonicalization for a local or Git source, before dry-run +planning begins. Its output describes selected intent, not runtime outcome: a +provider may be missing, a package may already be installed, a command check +may skip exec, a Fetch Content target may conflict, and a link may already be +satisfied or may fail during apply. Selected planning is atomic. Every selector is valid and every selected provider and job is resolved before an ExecutionPlan exists, so apply executes @@ -791,33 +830,33 @@ failures remain provider-local and do not stop later provider attempts. The v0.1.0 command facade is explicit: ``` -dot apply -dot dry-run -dot check providers -dot list targets [--all] -dot list profiles -dot list jobs +dot [--config SOURCE] +dot --git REPOSITORY --git-worktree PATH + +COMMAND: +apply +dry-run +check providers +list targets [--all] +list profiles +list jobs ``` There is no default operation. `dot` without a subcommand prints help and is an -error. `--config PATH` is global. The CLI represents its presence or absence as -a typed ConfigRequest, and the central application facade resolves that request -exactly once before dispatching an operation. An explicit path bypasses -discovery and may use any filename. Otherwise dot checks `./.dot.toml`, then -`~/.config/dot/.dot.toml` on Linux and macOS or -`%APPDATA%\dot\.dot.toml` on Windows. The first existing filesystem entry is -selected; load, parse, and validation failures do not fall through. Discovery -does not recursively search, merge files, or recognize `dot.toml` as a legacy -default. It uses native host paths and is not affected by an injected -PlatformInfo. Once selected, the entry is made absolute without following -symlinks, then eagerly canonicalized and read through its canonical entity -path as described above. - -Target and profile options belong to `apply`, `dry-run`, `check providers`, and -`list jobs`. `list profiles` accepts only the target option. `list targets` -accepts only `--all`. Target may be omitted only when exactly one configured -target is compatible. Profile omission and `--profile @root` both select the -target root. +error. `--config SOURCE` is global, accepts a local path or HTTPS URL, and +conflicts with `--git`. `--git` and `--git-worktree` are global and require one +another. With none of those options, the CLI performs local discovery. Its +typed ConfigRequest is resolved exactly once before operation dispatch; any +acquisition, parse, or static-validation failure therefore occurs before list, +check, dry-run, or apply begins. The acquisition policies and path context are +defined above. Native local discovery is not affected by an injected +PlatformInfo. + +The configured-target option `--target TARGET` and the profile option belong to +`apply`, `dry-run`, `check providers`, and `list jobs`. `list profiles` accepts +only the target option. `list targets` accepts only `--all`. Target may be +omitted only when exactly one configured target is compatible. Profile +omission and `--profile @root` both select the target root. Apply and dry-run additionally accept repeatable exact job options: @@ -850,8 +889,8 @@ statically validate the complete configuration, then stop at structural, unresolved data. They may detect host compatibility facts, which can read OS or container metadata for target labels, filtering, and inference. They do not inspect managed link or source state, resolve runtime values, or run commands. -Their shared configuration load still canonicalizes the selected entry before -reading it. +Their shared configuration load still completes source acquisition before +inspection, including entry canonicalization for a local or Git source. `list targets` returns only compatible targets by default; `--all` returns every target with a compatibility label. `list profiles` returns `@root` and @@ -980,11 +1019,12 @@ its row order is not guaranteed. ### apply pipeline -1. Make the selected configuration entry absolute, canonicalize it eagerly, - read the canonical entity, parse all TOML records structurally, promote - every source expression, and validate provider rules against every - effective target-root/profile merge. Loading diagnostics retain the entry - path. +1. Acquire exactly one configuration source. Local and Git sources make the + selected entry absolute, canonicalize it eagerly, and read the canonical + entity; HTTPS is fetched once into memory. Parse all TOML records + structurally, promote every source expression, and validate provider rules + against every effective target-root/profile merge. Local diagnostics retain + the entry path and remote diagnostics retain the source URL. 2. Detect the platform, select one compatible target and zero or one profile, and reject ambiguity or incompatibility. 3. Overlay target and profile declarations from root to the selected node, From 829da494d630d5fc839f9a5ea1765d42122f92c5 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Thu, 6 Aug 2026 22:41:53 +0800 Subject: [PATCH 4/6] fix: preserve native config paths --- dot-cli/src/config.rs | 10 +++++++++ dot-cli/src/main.rs | 39 ++++++++++++++++++++++++++++++++- dot-cli/tests/cli.rs | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/dot-cli/src/config.rs b/dot-cli/src/config.rs index f8154b3..35c38d7 100644 --- a/dot-cli/src/config.rs +++ b/dot-cli/src/config.rs @@ -6,6 +6,7 @@ )] use std::env; +use std::ffi::OsString; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -33,6 +34,15 @@ pub(crate) enum ConfigSource { Https(Url), } +impl ConfigSource { + pub(crate) fn from_os_string(value: OsString) -> Result { + match value.into_string() { + Ok(value) => value.parse(), + Err(value) => Ok(Self::Path(PathBuf::from(value))), + } + } +} + impl FromStr for ConfigSource { type Err = ConfigSourceError; diff --git a/dot-cli/src/main.rs b/dot-cli/src/main.rs index ee55be8..874a7d6 100644 --- a/dot-cli/src/main.rs +++ b/dot-cli/src/main.rs @@ -3,6 +3,7 @@ use std::io::{self, IsTerminal, Write}; use std::path::PathBuf; use std::process::ExitCode; +use clap::builder::{OsStringValueParser, TypedValueParser}; use clap::error::ErrorKind; use clap::{Args, CommandFactory, Error, Parser, Subcommand}; @@ -31,7 +32,13 @@ mod platform_override; )] struct Cli { /// Path or HTTPS URL to the TOML manifest; defaults to ./.dot.toml, then the user fallback - #[arg(short, long, global = true, value_name = "SOURCE")] + #[arg( + short, + long, + global = true, + value_name = "SOURCE", + value_parser = OsStringValueParser::new().try_map(ConfigSource::from_os_string) + )] config: Option, /// Git repository containing a root .dot.toml @@ -363,9 +370,39 @@ fn main() -> ExitCode { #[cfg(test)] mod tests { + #[cfg(unix)] + use std::ffi::OsString; use std::io; + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt; + #[cfg(unix)] + use std::path::PathBuf; + + #[cfg(unix)] + use clap::Parser; use super::normalize_list_output; + #[cfg(unix)] + use super::{Cli, ConfigSource}; + + #[cfg(unix)] + #[test] + fn config_argument_preserves_non_utf8_native_paths() { + let source = OsString::from_vec(b"config-\xff.toml".to_vec()); + let parsed = Cli::try_parse_from([ + OsString::from("dot"), + OsString::from("--config"), + source.clone(), + OsString::from("list"), + OsString::from("targets"), + ]) + .expect("non-UTF-8 native path should parse"); + + assert_eq!( + parsed.config, + Some(ConfigSource::Path(PathBuf::from(source))) + ); + } #[test] fn list_output_ignores_only_broken_pipe() { diff --git a/dot-cli/tests/cli.rs b/dot-cli/tests/cli.rs index fa5c8f6..fdfc2bc 100644 --- a/dot-cli/tests/cli.rs +++ b/dot-cli/tests/cli.rs @@ -1,5 +1,12 @@ use std::process::{Command, Output}; +#[cfg(unix)] +use std::ffi::OsString; +#[cfg(unix)] +use std::fs; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; + fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_dot")) .args(args) @@ -113,6 +120,49 @@ fn rejects_unsupported_config_source_protocols_during_cli_parsing() { assert!(stderr.contains("protocol `http`"), "{stderr}"); } +#[cfg(unix)] +#[test] +fn accepts_a_non_utf8_local_config_path() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "dot-cli-non-utf8-config-{}-{nonce}", + std::process::id() + )); + fs::create_dir(&root).expect("temporary directory should be created"); + let path = root.join(OsString::from_vec(b"config-\xff.toml".to_vec())); + let write_result = fs::write( + &path, + r#"[targets.current] +platform = { os = ["linux", "macos", "windows"] } +"#, + ); + if let Err(source) = write_result { + fs::remove_dir(&root).expect("temporary directory should be removed"); + #[cfg(target_vendor = "apple")] + { + // APFS rejects invalid-UTF-8 names before the CLI can observe them. + assert_eq!(source.raw_os_error(), Some(92), "unexpected write error"); + return; + } + #[cfg(not(target_vendor = "apple"))] + panic!("test manifest should be written: {source}"); + } + + let output = Command::new(env!("CARGO_BIN_EXE_dot")) + .arg("--config") + .arg(&path) + .args(["list", "targets"]) + .output() + .expect("dot should start"); + fs::remove_dir_all(&root).expect("temporary directory should be removed"); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), "{stderr}"); +} + #[test] fn requires_a_complete_non_conflicting_git_source_group() { for args in [ From 1110e3f42ed955edca5553b78342b46692847df5 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Fri, 7 Aug 2026 09:15:05 +0800 Subject: [PATCH 5/6] test: keep git fixtures portable on windows --- dot-cli/tests/config_source_command.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dot-cli/tests/config_source_command.rs b/dot-cli/tests/config_source_command.rs index b908e47..84cb72b 100644 --- a/dot-cli/tests/config_source_command.rs +++ b/dot-cli/tests/config_source_command.rs @@ -28,7 +28,16 @@ impl TempRepositories { )); let cwd = root.join("cwd"); fs::create_dir_all(&cwd).expect("temporary working directory should be created"); + #[cfg(not(windows))] let root = fs::canonicalize(root).expect("temporary root should canonicalize"); + #[cfg(windows)] + let root = { + assert!(root.is_absolute(), "temporary root should be absolute"); + // Git for Windows cannot read GIT_CONFIG_GLOBAL through the verbatim + // `\\?\` path returned by std::fs::canonicalize. Keep the already + // absolute temporary path in the spelling used by Git diagnostics. + root + }; let cwd = root.join("cwd"); Self { root, cwd } } From 7972c8d260a0d57a3672f3dbbb03ce02b29790a9 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Fri, 7 Aug 2026 10:28:30 +0800 Subject: [PATCH 6/6] refactor: use directory config module --- dot-cli/src/{config.rs => config/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dot-cli/src/{config.rs => config/mod.rs} (100%) diff --git a/dot-cli/src/config.rs b/dot-cli/src/config/mod.rs similarity index 100% rename from dot-cli/src/config.rs rename to dot-cli/src/config/mod.rs