1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use git2::{FetchOptions, RemoteCallbacks, Repository};
10use semver::{Version, VersionReq};
11
12use crate::config::{
13 ConfigSourceDocument, OriginSpec, OriginType, ProfileDocument, SourceSpec, parse_config_source,
14};
15use crate::errors::{Result, SourceError};
16use crate::output::{Printer, Role};
17
18const SOURCE_MANIFEST_FILE: &str = "cfgd-source.yaml";
19const PROFILES_DIR: &str = "profiles";
20
21#[derive(Debug, Clone)]
23pub struct CachedSource {
24 pub name: String,
25 pub origin_url: String,
26 pub origin_branch: String,
27 pub local_path: PathBuf,
28 pub manifest: ConfigSourceDocument,
29 pub last_commit: Option<String>,
30 pub last_fetched: Option<String>,
31}
32
33pub struct SourceManager {
35 cache_dir: PathBuf,
36 sources: HashMap<String, CachedSource>,
37 allow_unsigned: bool,
39}
40
41impl SourceManager {
42 pub fn new(cache_dir: &Path) -> Self {
44 Self {
45 cache_dir: cache_dir.to_path_buf(),
46 sources: HashMap::new(),
47 allow_unsigned: false,
48 }
49 }
50
51 pub fn set_allow_unsigned(&mut self, allow: bool) {
53 self.allow_unsigned = allow;
54 }
55
56 pub fn default_cache_dir() -> Result<PathBuf> {
58 let base = directories::BaseDirs::new().ok_or_else(|| SourceError::CacheError {
59 message: "cannot determine home directory".into(),
60 })?;
61 Ok(base.data_local_dir().join("cfgd").join("sources"))
62 }
63
64 pub fn load_sources(&mut self, sources: &[SourceSpec], printer: &Printer) -> Result<()> {
67 let mut loaded = 0;
68 for spec in sources {
69 match self.load_source(spec, printer) {
70 Ok(()) => loaded += 1,
71 Err(e) => {
72 printer.status_simple(
73 Role::Warn,
74 format!("Failed to load source '{}': {}", spec.name, e),
75 );
76 }
77 }
78 }
79 if !sources.is_empty() && loaded == 0 {
80 return Err(SourceError::GitError {
81 name: "all".to_string(),
82 message: "all sources failed to load".to_string(),
83 }
84 .into());
85 }
86 Ok(())
87 }
88
89 pub fn load_source(&mut self, spec: &SourceSpec, printer: &Printer) -> Result<()> {
91 crate::validate_no_traversal(std::path::Path::new(&spec.name)).map_err(|e| {
92 SourceError::GitError {
93 name: spec.name.clone(),
94 message: format!("invalid source name: {e}"),
95 }
96 })?;
97
98 let url_lower = spec.origin.url.to_lowercase();
101 let allow_local = std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok();
102 if !allow_local && (url_lower.starts_with("file://") || url_lower.starts_with('/')) {
103 return Err(SourceError::GitError {
104 name: spec.name.clone(),
105 message: "local file:// URLs and absolute paths are not allowed as source origins"
106 .to_string(),
107 }
108 .into());
109 }
110
111 let source_dir = self.cache_dir.join(&spec.name);
112
113 if source_dir.exists() {
114 self.fetch_source(spec, &source_dir, printer)?;
115 } else {
116 self.clone_source(spec, &source_dir, printer)?;
117 }
118
119 let manifest = self.parse_manifest(&spec.name, &source_dir)?;
120
121 self.verify_commit_signature(&spec.name, &source_dir, &manifest.spec.policy.constraints)?;
123
124 if let Some(ref pin) = spec.sync.pin_version {
126 self.check_version_pin(&spec.name, &manifest, pin)?;
127 }
128
129 let last_commit = Self::head_commit(&source_dir);
130
131 let cached = CachedSource {
132 name: spec.name.clone(),
133 origin_url: spec.origin.url.clone(),
134 origin_branch: spec.origin.branch.clone(),
135 local_path: source_dir,
136 manifest,
137 last_commit,
138 last_fetched: Some(crate::utc_now_iso8601()),
139 };
140
141 self.sources.insert(spec.name.clone(), cached);
142 Ok(())
143 }
144
145 fn fetch_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
147 let to_git_err = |e: git2::Error| SourceError::GitError {
148 name: spec.name.clone(),
149 message: e.to_string(),
150 };
151
152 let mut cmd = crate::git_cmd_safe(
154 Some(&spec.origin.url),
155 Some(spec.origin.ssh_strict_host_key_checking),
156 );
157 cmd.args([
158 "-C",
159 &source_dir.display().to_string(),
160 "fetch",
161 "origin",
162 &spec.origin.branch,
163 ]);
164 cmd.stdout(std::process::Stdio::piped());
166 cmd.stderr(std::process::Stdio::piped());
167
168 let label = format!("Fetching source '{}'", spec.name);
169 let cli_result = printer.run(&mut cmd, &label);
170 let cli_ok = matches!(&cli_result, Ok(output) if output.status.success());
171
172 if !cli_ok {
173 let spinner = printer.spinner(format!("Fetching source '{}' (libgit2)...", spec.name));
175
176 let repo = Repository::open(source_dir).map_err(to_git_err)?;
177
178 let mut remote = repo.find_remote("origin").map_err(to_git_err)?;
179
180 let mut fo = FetchOptions::new();
181 let mut callbacks = RemoteCallbacks::new();
182 callbacks.credentials(crate::git_ssh_credentials);
183 fo.remote_callbacks(callbacks);
184
185 let fetch_result = remote
186 .fetch(&[&spec.origin.branch], Some(&mut fo), None)
187 .map_err(|e| SourceError::FetchFailed {
188 name: spec.name.clone(),
189 message: e.to_string(),
190 });
191
192 match &fetch_result {
193 Ok(_) => {
194 let _ = spinner.finish_ok(format!("Fetched source '{}' (libgit2)", spec.name));
195 }
196 Err(e) => {
197 let _ = spinner
198 .finish_fail(format!("Failed to fetch source '{}' (libgit2)", spec.name))
199 .detail(crate::output::collapse_to_subject_line(e));
200 }
201 }
202 fetch_result?;
203 }
204
205 let repo = Repository::open(source_dir).map_err(to_git_err)?;
207
208 let fetch_head = repo.find_reference("FETCH_HEAD").map_err(to_git_err)?;
209 let fetch_commit = repo
210 .reference_to_annotated_commit(&fetch_head)
211 .map_err(to_git_err)?;
212
213 let (analysis, _) = repo.merge_analysis(&[&fetch_commit]).map_err(to_git_err)?;
214
215 if analysis.is_fast_forward() {
216 let refname = format!("refs/heads/{}", spec.origin.branch);
217 if let Ok(mut reference) = repo.find_reference(&refname) {
218 reference
219 .set_target(fetch_commit.id(), "cfgd source fetch")
220 .map_err(to_git_err)?;
221 }
222 repo.set_head(&refname).map_err(to_git_err)?;
223 repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
224 .map_err(to_git_err)?;
225 }
226
227 Ok(())
228 }
229
230 fn clone_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
233 if let Some(parent) = source_dir.parent() {
235 std::fs::create_dir_all(parent).map_err(|e| SourceError::CacheError {
236 message: format!("cannot create cache dir: {}", e),
237 })?;
238 }
239
240 let mut cmd = crate::git_cmd_safe(
245 Some(&spec.origin.url),
246 Some(spec.origin.ssh_strict_host_key_checking),
247 );
248 cmd.args([
249 "clone",
250 "--depth=1",
251 "--single-branch",
252 "--no-recurse-submodules",
253 "--branch",
254 &spec.origin.branch,
255 &spec.origin.url,
256 &source_dir.display().to_string(),
257 ]);
258 cmd.stdout(std::process::Stdio::piped());
259 cmd.stderr(std::process::Stdio::piped());
260
261 let label = format!("Cloning source '{}'", spec.name);
262 let cli_result = printer.run(&mut cmd, &label);
263 if matches!(&cli_result, Ok(output) if output.status.success()) {
264 let _ = crate::set_file_permissions(source_dir, 0o700);
266 return Ok(());
267 }
268
269 let _ = std::fs::remove_dir_all(source_dir);
271
272 let spinner = printer.spinner(format!("Cloning source '{}' (libgit2)...", spec.name));
274
275 let mut fo = FetchOptions::new();
276 if spec.origin.url.starts_with("git@") || spec.origin.url.starts_with("ssh://") {
277 let mut callbacks = RemoteCallbacks::new();
278 callbacks.credentials(crate::git_ssh_credentials);
279 fo.remote_callbacks(callbacks);
280 }
281
282 fo.depth(1);
284 let mut builder = git2::build::RepoBuilder::new();
285 builder.fetch_options(fo);
286 builder.branch(&spec.origin.branch);
287
288 let clone_result =
289 builder
290 .clone(&spec.origin.url, source_dir)
291 .map_err(|e| SourceError::FetchFailed {
292 name: spec.name.clone(),
293 message: e.to_string(),
294 });
295
296 match &clone_result {
297 Ok(_) => {
298 let _ = spinner.finish_ok(format!("Cloned source '{}' (libgit2)", spec.name));
299 }
300 Err(e) => {
301 let _ = spinner
302 .finish_fail(format!("Failed to clone source '{}' (libgit2)", spec.name))
303 .detail(crate::output::collapse_to_subject_line(e));
304 }
305 }
306 clone_result?;
307
308 let _ = crate::set_file_permissions(source_dir, 0o700);
310
311 Ok(())
312 }
313
314 pub fn parse_manifest(&self, name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
316 read_manifest(name, source_dir)
317 }
318
319 pub fn verify_commit_signature(
323 &self,
324 name: &str,
325 source_dir: &Path,
326 constraints: &crate::config::SourceConstraints,
327 ) -> Result<()> {
328 if !constraints.require_signed_commits {
329 return Ok(());
330 }
331
332 if self.allow_unsigned {
333 tracing::info!(
334 source = %name,
335 "Signature verification skipped for source '{}' (allow-unsigned is set)",
336 name
337 );
338 return Ok(());
339 }
340
341 verify_head_signature(name, source_dir)
342 }
343
344 fn check_version_pin(
346 &self,
347 name: &str,
348 manifest: &ConfigSourceDocument,
349 pin: &str,
350 ) -> Result<()> {
351 let version_str = manifest.metadata.version.as_deref().unwrap_or("0.0.0");
352
353 let version = Version::parse(version_str).map_err(|e| SourceError::InvalidManifest {
354 name: name.to_string(),
355 message: format!("invalid semver '{}': {}", version_str, e),
356 })?;
357
358 let normalized_pin = normalize_semver_pin(pin);
360 let req = VersionReq::parse(&normalized_pin).map_err(|_| SourceError::VersionMismatch {
361 name: name.to_string(),
362 version: version_str.to_string(),
363 pin: pin.to_string(),
364 })?;
365
366 if !req.matches(&version) {
367 return Err(SourceError::VersionMismatch {
368 name: name.to_string(),
369 version: version_str.to_string(),
370 pin: pin.to_string(),
371 }
372 .into());
373 }
374
375 Ok(())
376 }
377
378 fn head_commit(source_dir: &Path) -> Option<String> {
380 let repo = Repository::open(source_dir).ok()?;
381 let head = repo.head().ok()?;
382 head.target().map(|oid| oid.to_string())
383 }
384
385 pub fn get(&self, name: &str) -> Option<&CachedSource> {
387 self.sources.get(name)
388 }
389
390 pub fn all_sources(&self) -> &HashMap<String, CachedSource> {
392 &self.sources
393 }
394
395 pub fn load_source_profile(
397 &self,
398 source_name: &str,
399 profile_name: &str,
400 ) -> Result<ProfileDocument> {
401 let cached = self
402 .sources
403 .get(source_name)
404 .ok_or_else(|| SourceError::NotFound {
405 name: source_name.to_string(),
406 })?;
407
408 let profile_path = cached
409 .local_path
410 .join(PROFILES_DIR)
411 .join(format!("{}.yaml", profile_name));
412
413 if !profile_path.exists() {
414 return Err(SourceError::ProfileNotFound {
415 name: source_name.to_string(),
416 profile: profile_name.to_string(),
417 }
418 .into());
419 }
420
421 crate::config::load_profile(&profile_path)
422 }
423
424 pub fn source_profiles_dir(&self, source_name: &str) -> Result<PathBuf> {
426 let cached = self
427 .sources
428 .get(source_name)
429 .ok_or_else(|| SourceError::NotFound {
430 name: source_name.to_string(),
431 })?;
432 Ok(cached.local_path.join(PROFILES_DIR))
433 }
434
435 pub fn source_files_dir(&self, source_name: &str) -> Result<PathBuf> {
437 let cached = self
438 .sources
439 .get(source_name)
440 .ok_or_else(|| SourceError::NotFound {
441 name: source_name.to_string(),
442 })?;
443 Ok(cached.local_path.join("files"))
444 }
445
446 pub fn remove_source(&mut self, name: &str) -> Result<()> {
448 let cached = self
449 .sources
450 .remove(name)
451 .ok_or_else(|| SourceError::NotFound {
452 name: name.to_string(),
453 })?;
454
455 if cached.local_path.exists() {
456 std::fs::remove_dir_all(&cached.local_path).map_err(|e| SourceError::CacheError {
457 message: format!("failed to remove cache for '{}': {}", name, e),
458 })?;
459 }
460
461 Ok(())
462 }
463
464 pub fn build_source_spec(name: &str, url: &str, profile: Option<&str>) -> SourceSpec {
466 SourceSpec {
467 name: name.to_string(),
468 origin: OriginSpec {
469 origin_type: OriginType::Git,
470 url: url.to_string(),
471 branch: "master".to_string(),
472 auth: None,
473 ssh_strict_host_key_checking: Default::default(),
474 },
475 subscription: crate::config::SubscriptionSpec {
476 profile: profile.map(|s| s.to_string()),
477 ..Default::default()
478 },
479 sync: Default::default(),
480 }
481 }
482}
483
484fn read_manifest(name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
486 let manifest_path = source_dir.join(SOURCE_MANIFEST_FILE);
487 if !manifest_path.exists() {
488 return Err(SourceError::InvalidManifest {
489 name: name.to_string(),
490 message: format!("{} not found", SOURCE_MANIFEST_FILE),
491 }
492 .into());
493 }
494
495 let contents =
496 std::fs::read_to_string(&manifest_path).map_err(|e| SourceError::InvalidManifest {
497 name: name.to_string(),
498 message: e.to_string(),
499 })?;
500
501 let doc = parse_config_source(&contents).map_err(|e| SourceError::InvalidManifest {
502 name: name.to_string(),
503 message: e.to_string(),
504 })?;
505
506 if doc.spec.provides.profiles.is_empty() && doc.spec.provides.profile_details.is_empty() {
507 return Err(SourceError::NoProfiles {
508 name: name.to_string(),
509 }
510 .into());
511 }
512
513 Ok(doc)
514}
515
516pub fn detect_source_manifest(dir: &Path) -> Result<Option<ConfigSourceDocument>> {
520 let manifest_path = dir.join(SOURCE_MANIFEST_FILE);
521 if !manifest_path.exists() {
522 return Ok(None);
523 }
524 let name = dir
525 .file_name()
526 .and_then(|n| n.to_str())
527 .unwrap_or("unknown");
528 read_manifest(name, dir).map(Some)
529}
530
531pub fn verify_head_signature(name: &str, repo_dir: &Path) -> Result<()> {
536 if !crate::command_available("git") {
537 return Err(SourceError::SignatureVerificationFailed {
538 name: name.to_string(),
539 message: "git CLI is required for signature verification but is not available on PATH"
540 .into(),
541 }
542 .into());
543 }
544
545 let output = crate::command_output_with_timeout(
546 std::process::Command::new("git")
547 .args([
548 "-C",
549 &repo_dir.display().to_string(),
550 "log",
551 "--format=%G?",
552 "-1",
553 ])
554 .stdout(std::process::Stdio::piped())
555 .stderr(std::process::Stdio::piped()),
556 crate::COMMAND_TIMEOUT,
557 )
558 .map_err(|e| SourceError::SignatureVerificationFailed {
559 name: name.to_string(),
560 message: format!("failed to run git: {}", e),
561 })?;
562
563 if !output.status.success() {
564 return Err(SourceError::SignatureVerificationFailed {
565 name: name.to_string(),
566 message: format!(
567 "git log failed (exit {}): {}",
568 output.status.code().unwrap_or(-1),
569 crate::stderr_lossy_trimmed(&output)
570 ),
571 }
572 .into());
573 }
574
575 let status = crate::stdout_lossy_trimmed(&output);
576 classify_signature_status(name, &status)
577}
578
579pub(super) fn classify_signature_status(name: &str, status: &str) -> Result<()> {
594 match status {
595 "G" | "U" => {
596 tracing::info!(
597 source = %name,
598 "Source '{}' HEAD commit signature verified (status: {})",
599 name, status
600 );
601 Ok(())
602 }
603 "N" => Err(SourceError::SignatureVerificationFailed {
604 name: name.to_string(),
605 message: "HEAD commit is not signed — source requires signed commits".into(),
606 }
607 .into()),
608 "B" => Err(SourceError::SignatureVerificationFailed {
609 name: name.to_string(),
610 message: "HEAD commit has a bad (invalid) signature".into(),
611 }
612 .into()),
613 "E" => Err(SourceError::SignatureVerificationFailed {
614 name: name.to_string(),
615 message: "signature cannot be checked — ensure the signing key is imported".into(),
616 }
617 .into()),
618 "X" | "Y" => Err(SourceError::SignatureVerificationFailed {
619 name: name.to_string(),
620 message: "HEAD commit signature or signing key has expired".into(),
621 }
622 .into()),
623 "R" => Err(SourceError::SignatureVerificationFailed {
624 name: name.to_string(),
625 message: "HEAD commit was signed with a revoked key".into(),
626 }
627 .into()),
628 other => Err(SourceError::SignatureVerificationFailed {
629 name: name.to_string(),
630 message: format!("unexpected signature status '{}' from git", other),
631 }
632 .into()),
633 }
634}
635
636fn normalize_semver_pin(pin: &str) -> String {
642 let trimmed = pin.trim();
643
644 if let Some(rest) = trimmed.strip_prefix('~') {
645 let dots = rest.matches('.').count();
646 match dots {
647 0 => format!("^{}.0.0", rest),
649 1 => format!("~{}.0", rest),
651 _ => trimmed.to_string(),
652 }
653 } else if let Some(rest) = trimmed.strip_prefix('^') {
654 let dots = rest.matches('.').count();
655 match dots {
656 0 => format!("^{}.0.0", rest),
657 1 => format!("^{}.0", rest),
658 _ => trimmed.to_string(),
659 }
660 } else {
661 trimmed.to_string()
662 }
663}
664
665pub fn git_clone_with_fallback(
668 url: &str,
669 target: &Path,
670 printer: &Printer,
671) -> std::result::Result<(), String> {
672 let mut cmd = crate::git_cmd_safe(Some(url), None);
674 cmd.args([
675 "clone",
676 "--depth=1",
677 "--no-recurse-submodules",
678 url,
679 &target.display().to_string(),
680 ]);
681 cmd.stdout(std::process::Stdio::piped());
682 cmd.stderr(std::process::Stdio::piped());
683
684 let label = format!("Cloning {}", url);
685 let cli_result = printer.run(&mut cmd, &label);
686 if matches!(&cli_result, Ok(output) if output.status.success()) {
687 return Ok(());
688 }
689
690 let _ = std::fs::remove_dir_all(target);
692 let _ = std::fs::create_dir_all(target);
693
694 let spinner = printer.spinner("Cloning (libgit2)...");
696
697 let mut fetch_opts = git2::FetchOptions::new();
698 fetch_opts.depth(1);
699 if url.starts_with("git@") || url.starts_with("ssh://") {
700 let mut callbacks = git2::RemoteCallbacks::new();
701 callbacks.credentials(crate::git_ssh_credentials);
702 fetch_opts.remote_callbacks(callbacks);
703 }
704 let mut builder = git2::build::RepoBuilder::new();
705 builder.fetch_options(fetch_opts);
706
707 let result = builder
708 .clone(url, target)
709 .map(|_| ())
710 .map_err(|e| format!("Failed to clone {}: {}", url, e));
711
712 match &result {
713 Ok(_) => {
714 let _ = spinner.finish_ok(format!("Cloned {} (libgit2)", url));
715 }
716 Err(msg) => {
717 let _ = spinner
718 .finish_fail(format!("Failed to clone {} (libgit2)", url))
719 .detail(crate::output::collapse_to_subject_line(msg));
720 }
721 }
722 result
723}
724
725#[cfg(test)]
726mod tests;