1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8pub mod security;
9pub mod structural_merge;
10pub use security::{
11 MAX_MANAGED_FILE_BYTES, ensure_no_symlink_in_chain, is_windows_reserved_device_name,
12 read_bounded_file, redact_sensitive_text, scan_for_hardcoded_secrets, validate_target_name,
13};
14pub use structural_merge::{
15 FileFormat, StructuralConflict, merge_json_structural, merge_toml_structural,
16 merge_yaml_structural, three_way_merge_by_path,
17};
18
19pub const MANIFEST_FILE_NAME: &str = "drift.toml";
20pub const LOCK_FILE_NAME: &str = ".driftsync.lock";
21
22#[derive(Debug, Error)]
23pub enum DriftError {
24 #[error("invalid or unsafe relative path: '{0}'")]
25 UnsafePath(String),
26 #[error("invalid or unsafe target repository name: '{0}'")]
27 InvalidTargetName(String),
28 #[error("symlink traversal blocked at '{0}'")]
29 SymlinkDetected(PathBuf),
30 #[error("file exceeds size limit ({path}: {size_bytes} bytes > max {max_bytes} bytes)")]
31 FileTooLarge {
32 path: PathBuf,
33 size_bytes: u64,
34 max_bytes: u64,
35 },
36 #[error(
37 "hardcoded secret detected in '{location}' ({secret_kind}); use environment variables or CI secrets instead"
38 )]
39 SecretDetected {
40 location: String,
41 secret_kind: &'static str,
42 },
43 #[error("I/O error ({path}): {source}")]
44 Io {
45 path: PathBuf,
46 #[source]
47 source: std::io::Error,
48 },
49 #[error("TOML parse error ({path}): {source}")]
50 TomlDe {
51 path: PathBuf,
52 #[source]
53 source: Box<toml::de::Error>,
54 },
55 #[error("TOML serialization error: {0}")]
56 TomlSer(#[from] Box<toml::ser::Error>),
57 #[error("unsupported manifest version: {0}")]
58 UnsupportedVersion(u32),
59 #[error("undefined template variable '{variable}' (template: {path}, target: '{target_name}')")]
60 UndefinedTemplateVar {
61 path: PathBuf,
62 variable: String,
63 target_name: String,
64 },
65}
66
67pub fn validate_relative_path(raw: &str) -> Result<PathBuf, DriftError> {
68 let trimmed = raw.trim();
69 if trimmed.is_empty()
70 || trimmed.contains('\\')
71 || trimmed.starts_with('/')
72 || trimmed.chars().any(|c| c.is_ascii_control())
73 {
74 return Err(DriftError::UnsafePath(raw.to_string()));
75 }
76 let path = Path::new(trimmed);
77 let mut normalized = PathBuf::new();
78 for comp in path.components() {
79 match comp {
80 Component::Normal(part) => {
81 let s = part.to_string_lossy();
82 if s.is_empty()
83 || s.contains(':')
84 || s.ends_with('.')
85 || s.ends_with(' ')
86 || s.eq_ignore_ascii_case(".git")
87 || s.eq_ignore_ascii_case(LOCK_FILE_NAME)
88 || is_windows_reserved_device_name(&s)
89 {
90 return Err(DriftError::UnsafePath(raw.to_string()));
91 }
92 normalized.push(part);
93 }
94 Component::CurDir => {}
95 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
96 return Err(DriftError::UnsafePath(raw.to_string()));
97 }
98 }
99 }
100 if normalized.as_os_str().is_empty() {
101 return Err(DriftError::UnsafePath(raw.to_string()));
102 }
103 Ok(normalized)
104}
105
106pub fn hash_content(content: &str) -> String {
107 let normalized = normalize_newlines(content);
108 let hash = blake3::hash(normalized.as_bytes());
109 format!("blake3:{}", hash.to_hex())
110}
111
112pub fn normalize_newlines(input: &str) -> String {
113 input.replace("\r\n", "\n")
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
117#[serde(rename_all = "kebab-case")]
118pub enum SyncStrategy {
119 #[default]
120 ThreeWay,
121 Overwrite,
122 KeepLocal,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Manifest {
127 #[serde(default = "default_version")]
128 pub version: u32,
129 #[serde(default)]
130 pub vars: BTreeMap<String, String>,
131 #[serde(default)]
132 pub targets: Vec<TargetRepo>,
133 #[serde(default)]
134 pub files: Vec<ManagedFile>,
135}
136
137fn default_version() -> u32 {
138 1
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct TargetRepo {
143 pub name: String,
144 #[serde(default)]
145 pub path: String,
146 #[serde(default)]
147 pub repo: Option<String>,
148 #[serde(default)]
149 pub vars: BTreeMap<String, String>,
150}
151
152impl TargetRepo {
153 pub fn effective_path(&self) -> String {
154 if !self.path.trim().is_empty() {
155 self.path.clone()
156 } else {
157 format!(".driftsync-remotes/{}", self.name)
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ManagedFile {
164 pub source: String,
165 pub target: String,
166 #[serde(default)]
167 pub strategy: SyncStrategy,
168}
169
170impl Manifest {
171 pub fn load_from_path(path: &Path) -> Result<Self, DriftError> {
172 let raw = read_bounded_file(path)?;
173 let manifest: Self = toml::from_str(&raw).map_err(|source| DriftError::TomlDe {
174 path: path.to_path_buf(),
175 source: Box::new(source),
176 })?;
177 manifest.validate()?;
178 Ok(manifest)
179 }
180
181 pub fn validate(&self) -> Result<(), DriftError> {
182 if self.version != 1 {
183 return Err(DriftError::UnsupportedVersion(self.version));
184 }
185 for (k, v) in &self.vars {
186 if let Some(secret_kind) = scan_for_hardcoded_secrets(v) {
187 return Err(DriftError::SecretDetected {
188 location: format!("drift.toml [vars.{k}]"),
189 secret_kind,
190 });
191 }
192 }
193 for target in &self.targets {
194 validate_target_name(&target.name)?;
195 if !target.path.trim().is_empty() {
196 validate_relative_path(&target.path)?;
197 }
198 for (k, v) in &target.vars {
199 if let Some(secret_kind) = scan_for_hardcoded_secrets(v) {
200 return Err(DriftError::SecretDetected {
201 location: format!("drift.toml [targets({}).vars.{k}]", target.name),
202 secret_kind,
203 });
204 }
205 }
206 }
207 for file in &self.files {
208 validate_relative_path(&file.source)?;
209 validate_relative_path(&file.target)?;
210 }
211 Ok(())
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct LockFile {
217 #[serde(default = "default_version")]
218 pub version: u32,
219 #[serde(default)]
220 pub upstream_revision: Option<String>,
221 #[serde(default)]
222 pub files: BTreeMap<String, LockedFileEntry>,
223}
224
225impl Default for LockFile {
226 fn default() -> Self {
227 Self {
228 version: 1,
229 upstream_revision: None,
230 files: BTreeMap::new(),
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct LockedFileEntry {
237 pub base_hash: String,
238 pub base_content: String,
239}
240
241impl LockFile {
242 pub fn load_optional(repo_root: &Path) -> Result<Self, DriftError> {
243 let lock_path = ensure_no_symlink_in_chain(repo_root, Path::new(LOCK_FILE_NAME))?;
244 if !lock_path.exists() {
245 return Ok(Self::default());
246 }
247 let raw = read_bounded_file(&lock_path)?;
248 let lock: Self = toml::from_str(&raw).map_err(|source| DriftError::TomlDe {
249 path: lock_path,
250 source: Box::new(source),
251 })?;
252 Ok(lock)
253 }
254
255 pub fn save(&self, repo_root: &Path) -> Result<(), DriftError> {
256 let lock_path = ensure_no_symlink_in_chain(repo_root, Path::new(LOCK_FILE_NAME))?;
257 let serialized = toml::to_string_pretty(self).map_err(Box::new)?;
258 fs::write(&lock_path, serialized).map_err(|source| DriftError::Io {
259 path: lock_path,
260 source,
261 })
262 }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "kebab-case")]
267pub enum DriftStatus {
268 InSync,
269 MissingInTarget,
270 UntrackedIdentical,
271 UntrackedDifferent,
272 UpstreamChanged,
273 LocallyModified,
274 BothChangedClean,
275 BothChangedConflict,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(tag = "action", rename_all = "kebab-case")]
280pub enum PlanAction {
281 NoOp,
282 Create {
283 new_content: String,
284 },
285 RecordLock {
286 base_content: String,
287 },
288 NeedsAdopt,
289 UpdateFastForward {
290 new_content: String,
291 },
292 KeepLocal,
293 MergeClean {
294 merged_content: String,
295 new_base_content: String,
296 },
297 OverwriteWithUpstream {
298 new_content: String,
299 },
300 Conflict {
301 conflict_preview: String,
302 },
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct FileEvaluation {
307 pub source_path: String,
308 pub target_path: String,
309 pub strategy: SyncStrategy,
310 pub status: DriftStatus,
311 pub plan: PlanAction,
312 pub unified_diff: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct RepoReport {
317 pub target_name: String,
318 pub target_path: String,
319 pub evaluations: Vec<FileEvaluation>,
320}
321
322pub fn evaluate_file(
323 source_path: &str,
324 target_path: &str,
325 strategy: SyncStrategy,
326 base: Option<&str>,
327 ours: Option<&str>,
328 theirs: &str,
329) -> FileEvaluation {
330 let base_norm = base.map(normalize_newlines);
331 let ours_norm = ours.map(normalize_newlines);
332 let theirs_norm = normalize_newlines(theirs);
333
334 let (status, plan, preview_after) = match (base_norm.as_deref(), ours_norm.as_deref()) {
335 (_, None) => (
336 DriftStatus::MissingInTarget,
337 PlanAction::Create {
338 new_content: theirs_norm.clone(),
339 },
340 theirs_norm.clone(),
341 ),
342 (None, Some(ours_str)) => {
343 if ours_str == theirs_norm {
344 (
345 DriftStatus::UntrackedIdentical,
346 PlanAction::RecordLock {
347 base_content: theirs_norm.clone(),
348 },
349 ours_str.to_string(),
350 )
351 } else {
352 match strategy {
353 SyncStrategy::Overwrite => (
354 DriftStatus::UntrackedDifferent,
355 PlanAction::OverwriteWithUpstream {
356 new_content: theirs_norm.clone(),
357 },
358 theirs_norm.clone(),
359 ),
360 SyncStrategy::KeepLocal => (
361 DriftStatus::UntrackedDifferent,
362 PlanAction::KeepLocal,
363 ours_str.to_string(),
364 ),
365 SyncStrategy::ThreeWay => (
366 DriftStatus::UntrackedDifferent,
367 PlanAction::NeedsAdopt,
368 ours_str.to_string(),
369 ),
370 }
371 }
372 }
373 (Some(base_str), Some(ours_str)) => {
374 if ours_str == theirs_norm && base_str == theirs_norm {
375 (DriftStatus::InSync, PlanAction::NoOp, ours_str.to_string())
376 } else if ours_str == theirs_norm && base_str != theirs_norm {
377 (
378 DriftStatus::InSync,
379 PlanAction::RecordLock {
380 base_content: theirs_norm.clone(),
381 },
382 ours_str.to_string(),
383 )
384 } else if ours_str == base_str && theirs_norm != base_str {
385 match strategy {
386 SyncStrategy::KeepLocal => (
387 DriftStatus::UpstreamChanged,
388 PlanAction::KeepLocal,
389 ours_str.to_string(),
390 ),
391 SyncStrategy::ThreeWay | SyncStrategy::Overwrite => (
392 DriftStatus::UpstreamChanged,
393 PlanAction::UpdateFastForward {
394 new_content: theirs_norm.clone(),
395 },
396 theirs_norm.clone(),
397 ),
398 }
399 } else if ours_str != base_str && theirs_norm == base_str {
400 match strategy {
401 SyncStrategy::Overwrite => (
402 DriftStatus::LocallyModified,
403 PlanAction::OverwriteWithUpstream {
404 new_content: theirs_norm.clone(),
405 },
406 theirs_norm.clone(),
407 ),
408 SyncStrategy::ThreeWay | SyncStrategy::KeepLocal => (
409 DriftStatus::LocallyModified,
410 PlanAction::KeepLocal,
411 ours_str.to_string(),
412 ),
413 }
414 } else {
415 match strategy {
416 SyncStrategy::Overwrite => (
417 DriftStatus::BothChangedClean,
418 PlanAction::OverwriteWithUpstream {
419 new_content: theirs_norm.clone(),
420 },
421 theirs_norm.clone(),
422 ),
423 SyncStrategy::KeepLocal => (
424 DriftStatus::BothChangedClean,
425 PlanAction::KeepLocal,
426 ours_str.to_string(),
427 ),
428 SyncStrategy::ThreeWay => {
429 match three_way_merge_by_path(target_path, base_str, ours_str, &theirs_norm)
430 {
431 Ok(merged) => (
432 DriftStatus::BothChangedClean,
433 PlanAction::MergeClean {
434 merged_content: merged.clone(),
435 new_base_content: theirs_norm.clone(),
436 },
437 merged,
438 ),
439 Err(conflict_text) => (
440 DriftStatus::BothChangedConflict,
441 PlanAction::Conflict {
442 conflict_preview: conflict_text.clone(),
443 },
444 conflict_text,
445 ),
446 }
447 }
448 }
449 }
450 }
451 };
452
453 let current_for_diff = ours_norm.as_deref().unwrap_or("");
454 let unified_diff = if current_for_diff == preview_after {
455 String::new()
456 } else {
457 let raw_diff = similar::TextDiff::from_lines(current_for_diff, &preview_after)
458 .unified_diff()
459 .context_radius(3)
460 .header(
461 &format!("a/{target_path} (ours)"),
462 &format!("b/{target_path} (proposed)"),
463 )
464 .to_string();
465 redact_sensitive_text(&raw_diff)
466 };
467
468 FileEvaluation {
469 source_path: source_path.to_string(),
470 target_path: target_path.to_string(),
471 strategy,
472 status,
473 plan,
474 unified_diff,
475 }
476}
477
478pub fn render_template(
479 input: &str,
480 target: &TargetRepo,
481 global_vars: &BTreeMap<String, String>,
482 source_path: &Path,
483) -> Result<String, DriftError> {
484 let mut output = String::with_capacity(input.len());
485 let mut rest = input;
486 let mut prev_ended_with_dollar = false;
487
488 while let Some(open_idx) = rest.find("{{") {
489 let before = &rest[..open_idx];
490 output.push_str(before);
491
492 let is_escaped_by_dollar = if open_idx > 0 {
493 before.ends_with('$')
494 } else {
495 prev_ended_with_dollar
496 };
497
498 let after_open = &rest[open_idx + 2..];
499 if is_escaped_by_dollar {
500 output.push_str("{{");
501 rest = after_open;
502 prev_ended_with_dollar = false;
503 continue;
504 }
505
506 if let Some(close_idx) = after_open.find("}}") {
507 let expr = after_open[..close_idx].trim();
508 if let Some(field) = expr.strip_prefix("target.") {
509 let field = field.trim();
510 match field {
511 "name" => output.push_str(&target.name),
512 "path" => output.push_str(&target.path),
513 _ => {
514 return Err(DriftError::UndefinedTemplateVar {
515 path: source_path.to_path_buf(),
516 variable: expr.to_string(),
517 target_name: target.name.clone(),
518 });
519 }
520 }
521 } else if let Some(var_key) = expr.strip_prefix("vars.") {
522 let var_key = var_key.trim();
523 if let Some(val) = target
524 .vars
525 .get(var_key)
526 .or_else(|| global_vars.get(var_key))
527 {
528 output.push_str(val);
529 } else {
530 return Err(DriftError::UndefinedTemplateVar {
531 path: source_path.to_path_buf(),
532 variable: expr.to_string(),
533 target_name: target.name.clone(),
534 });
535 }
536 } else {
537 output.push_str("{{");
538 output.push_str(&after_open[..close_idx]);
539 output.push_str("}}");
540 }
541
542 let next_start = close_idx + 2;
543 prev_ended_with_dollar = after_open[..next_start].ends_with('$');
544 rest = &after_open[next_start..];
545 } else {
546 output.push_str(&rest[open_idx..]);
547 rest = "";
548 break;
549 }
550 }
551
552 output.push_str(rest);
553 Ok(output)
554}
555
556pub fn evaluate_workspace(
557 source_root: &Path,
558 manifest: &Manifest,
559) -> Result<Vec<RepoReport>, DriftError> {
560 manifest.validate()?;
561 let mut reports = Vec::with_capacity(manifest.targets.len());
562
563 for target in &manifest.targets {
564 let effective_path = target.effective_path();
565 let repo_rel = validate_relative_path(&effective_path)?;
566 let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
567 let lock = LockFile::load_optional(&repo_root)?;
568 let mut evaluations = Vec::with_capacity(manifest.files.len());
569
570 for file in &manifest.files {
571 let src_rel = validate_relative_path(&file.source)?;
572 let tgt_rel = validate_relative_path(&file.target)?;
573
574 let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
575 let raw_theirs = read_bounded_file(&src_full)?;
576 if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
577 return Err(DriftError::SecretDetected {
578 location: file.source.clone(),
579 secret_kind,
580 });
581 }
582 let theirs = render_template(&raw_theirs, target, &manifest.vars, &src_full)?;
583 if let Some(secret_kind) = scan_for_hardcoded_secrets(&theirs) {
584 return Err(DriftError::SecretDetected {
585 location: format!("{} (target: {})", file.source, target.name),
586 secret_kind,
587 });
588 }
589
590 let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
591 let ours = if tgt_full.exists() {
592 Some(read_bounded_file(&tgt_full)?)
593 } else {
594 None
595 };
596
597 let base = lock
598 .files
599 .get(&file.target)
600 .map(|entry| entry.base_content.as_str());
601
602 let eval = evaluate_file(
603 &file.source,
604 &file.target,
605 file.strategy,
606 base,
607 ours.as_deref(),
608 &theirs,
609 );
610 evaluations.push(eval);
611 }
612
613 reports.push(RepoReport {
614 target_name: target.name.clone(),
615 target_path: effective_path,
616 evaluations,
617 });
618 }
619
620 Ok(reports)
621}
622
623#[derive(Debug, Clone, Default, Serialize, Deserialize)]
624pub struct ApplySummary {
625 pub dry_run: bool,
626 pub files_written: usize,
627 pub locks_updated: usize,
628 pub conflicts_skipped: usize,
629 pub needs_adopt_skipped: usize,
630}
631
632pub fn apply_workspace(
633 source_root: &Path,
634 manifest: &Manifest,
635 upstream_revision: Option<String>,
636 write: bool,
637) -> Result<(Vec<RepoReport>, ApplySummary), DriftError> {
638 let reports = evaluate_workspace(source_root, manifest)?;
639 let mut summary = ApplySummary {
640 dry_run: !write,
641 ..Default::default()
642 };
643
644 for (target, report) in manifest.targets.iter().zip(reports.iter()) {
645 let repo_rel = validate_relative_path(&target.effective_path())?;
646 let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
647 let mut lock = LockFile::load_optional(&repo_root)?;
648 let mut lock_dirty = false;
649
650 for eval in &report.evaluations {
651 let tgt_rel = validate_relative_path(&eval.target_path)?;
652 let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
653
654 match &eval.plan {
655 PlanAction::NoOp | PlanAction::KeepLocal => {}
656 PlanAction::NeedsAdopt => {
657 summary.needs_adopt_skipped += 1;
658 }
659 PlanAction::Conflict { .. } => {
660 summary.conflicts_skipped += 1;
661 }
662 PlanAction::RecordLock { base_content } => {
663 lock.files.insert(
664 eval.target_path.clone(),
665 LockedFileEntry {
666 base_hash: hash_content(base_content),
667 base_content: base_content.clone(),
668 },
669 );
670 lock_dirty = true;
671 }
672 PlanAction::Create { new_content }
673 | PlanAction::UpdateFastForward { new_content }
674 | PlanAction::OverwriteWithUpstream { new_content } => {
675 if write {
676 if let Some(parent) = tgt_full.parent() {
677 fs::create_dir_all(parent).map_err(|source| DriftError::Io {
678 path: parent.to_path_buf(),
679 source,
680 })?;
681 }
682 fs::write(&tgt_full, new_content).map_err(|source| DriftError::Io {
683 path: tgt_full.clone(),
684 source,
685 })?;
686 }
687 summary.files_written += 1;
688 lock.files.insert(
689 eval.target_path.clone(),
690 LockedFileEntry {
691 base_hash: hash_content(new_content),
692 base_content: new_content.clone(),
693 },
694 );
695 lock_dirty = true;
696 }
697 PlanAction::MergeClean {
698 merged_content,
699 new_base_content,
700 } => {
701 if write {
702 if let Some(parent) = tgt_full.parent() {
703 fs::create_dir_all(parent).map_err(|source| DriftError::Io {
704 path: parent.to_path_buf(),
705 source,
706 })?;
707 }
708 fs::write(&tgt_full, merged_content).map_err(|source| DriftError::Io {
709 path: tgt_full.clone(),
710 source,
711 })?;
712 }
713 summary.files_written += 1;
714 lock.files.insert(
715 eval.target_path.clone(),
716 LockedFileEntry {
717 base_hash: hash_content(new_base_content),
718 base_content: new_base_content.clone(),
719 },
720 );
721 lock_dirty = true;
722 }
723 }
724 }
725
726 if lock_dirty {
727 if let Some(rev) = &upstream_revision {
728 lock.upstream_revision = Some(rev.clone());
729 }
730 if write {
731 fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
732 path: repo_root.clone(),
733 source,
734 })?;
735 lock.save(&repo_root)?;
736 }
737 summary.locks_updated += 1;
738 }
739 }
740
741 Ok((reports, summary))
742}
743
744pub fn adopt_workspace(
745 source_root: &Path,
746 manifest: &Manifest,
747 upstream_revision: Option<String>,
748) -> Result<usize, DriftError> {
749 manifest.validate()?;
750 let mut updated_repos = 0usize;
751
752 for target in &manifest.targets {
753 let repo_rel = validate_relative_path(&target.effective_path())?;
754 let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
755 let mut lock = LockFile::load_optional(&repo_root)?;
756 let mut changed = false;
757
758 for file in &manifest.files {
759 if lock.files.contains_key(&file.target) {
760 continue;
761 }
762 let src_rel = validate_relative_path(&file.source)?;
763 let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
764 let raw_theirs = read_bounded_file(&src_full)?;
765 if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
766 return Err(DriftError::SecretDetected {
767 location: file.source.clone(),
768 secret_kind,
769 });
770 }
771 let rendered_theirs = render_template(&raw_theirs, target, &manifest.vars, &src_full)?;
772 if let Some(secret_kind) = scan_for_hardcoded_secrets(&rendered_theirs) {
773 return Err(DriftError::SecretDetected {
774 location: format!("{} (target: {})", file.source, target.name),
775 secret_kind,
776 });
777 }
778 let theirs = normalize_newlines(&rendered_theirs);
779
780 lock.files.insert(
781 file.target.clone(),
782 LockedFileEntry {
783 base_hash: hash_content(&theirs),
784 base_content: theirs,
785 },
786 );
787 changed = true;
788 }
789
790 if changed {
791 if let Some(rev) = &upstream_revision {
792 lock.upstream_revision = Some(rev.clone());
793 }
794 fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
795 path: repo_root.clone(),
796 source,
797 })?;
798 lock.save(&repo_root)?;
799 updated_repos += 1;
800 }
801 }
802
803 Ok(updated_repos)
804}
805
806pub fn has_actionable_drift(reports: &[RepoReport]) -> bool {
807 reports.iter().any(|repo| {
808 repo.evaluations.iter().any(|eval| {
809 !matches!(
810 eval.status,
811 DriftStatus::InSync | DriftStatus::LocallyModified
812 )
813 })
814 })
815}
816
817fn status_markdown_badge(status: DriftStatus) -> &'static str {
818 match status {
819 DriftStatus::InSync => "✅ `IN-SYNC`",
820 DriftStatus::MissingInTarget => "➕ `MISSING`",
821 DriftStatus::UntrackedIdentical => "🔵 `UNTRACKED-SAME`",
822 DriftStatus::UntrackedDifferent => "⚠️ `NEEDS-ADOPT`",
823 DriftStatus::UpstreamChanged => "⚡ `UPSTREAM-CHANGED`",
824 DriftStatus::LocallyModified => "🛡️ `LOCALLY-MODIFIED`",
825 DriftStatus::BothChangedClean => "🔀 `3-WAY-MERGE`",
826 DriftStatus::BothChangedConflict => "❌ `CONFLICT`",
827 }
828}
829
830fn plan_markdown_label(plan: &PlanAction) -> &'static str {
831 match plan {
832 PlanAction::NoOp => "No changes",
833 PlanAction::Create { .. } => "Create",
834 PlanAction::RecordLock { .. } => "Record lock",
835 PlanAction::NeedsAdopt => "Adoption required (`driftsync adopt`)",
836 PlanAction::UpdateFastForward { .. } => "Fast-forward update",
837 PlanAction::KeepLocal => "Keep local customization",
838 PlanAction::MergeClean { .. } => "3-way clean merge",
839 PlanAction::OverwriteWithUpstream { .. } => "Overwrite with upstream",
840 PlanAction::Conflict { .. } => "Conflict (`driftsync resolve` required)",
841 }
842}
843
844pub fn generate_markdown_report(reports: &[RepoReport]) -> String {
845 let mut out = String::from("## 🔄 driftsync — Multi-Repo Drift Report\n\n");
846 out.push_str("| Target Repo | File | Strategy | Status | Planned Action |\n");
847 out.push_str("|---|---|---|---|---|\n");
848
849 let mut diff_sections = String::new();
850 for repo in reports {
851 for eval in &repo.evaluations {
852 out.push_str(&format!(
853 "| **{}** (`{}`) | `{}` | `{:?}` | {} | {} |\n",
854 repo.target_name,
855 repo.target_path,
856 eval.target_path,
857 eval.strategy,
858 status_markdown_badge(eval.status),
859 plan_markdown_label(&eval.plan)
860 ));
861 if !eval.unified_diff.is_empty() {
862 let safe_diff = redact_sensitive_text(eval.unified_diff.trim_end());
863 diff_sections.push_str(&format!(
864 "\n<details>\n<summary><b>{} :: {}</b> ({})</summary>\n\n```diff\n{}\n```\n</details>\n",
865 repo.target_name,
866 eval.target_path,
867 status_markdown_badge(eval.status),
868 safe_diff
869 ));
870 }
871 }
872 }
873
874 if !diff_sections.is_empty() {
875 out.push_str("\n### 📝 Proposed Changes (Diff Preview)\n");
876 out.push_str(&diff_sections);
877 }
878
879 out
880}
881
882pub fn generate_pr_body(report: &RepoReport, upstream_revision: Option<&str>) -> String {
883 let mut out = format!(
884 "## 🔄 `driftsync` Configuration Sync (`{}`)\n\n",
885 report.target_name
886 );
887 if let Some(rev) = upstream_revision {
888 out.push_str(&format!("- **Upstream Revision:** `{rev}`\n"));
889 }
890 out.push_str(
891 "- **Policy:** 3-way structural merge applied while preserving local customizations.\n\n",
892 );
893
894 out.push_str("| Managed File | Drift Status | Applied Action |\n");
895 out.push_str("|---|---|---|\n");
896 for eval in &report.evaluations {
897 out.push_str(&format!(
898 "| `{}` | {} | {} |\n",
899 eval.target_path,
900 status_markdown_badge(eval.status),
901 plan_markdown_label(&eval.plan)
902 ));
903 }
904
905 let mut any_diff = false;
906 for eval in &report.evaluations {
907 if !eval.unified_diff.is_empty() {
908 if !any_diff {
909 out.push_str("\n### 🔍 File Diffs\n");
910 any_diff = true;
911 }
912 let safe_diff = redact_sensitive_text(eval.unified_diff.trim_end());
913 out.push_str(&format!(
914 "\n<details>\n<summary><code>{}</code></summary>\n\n```diff\n{}\n```\n</details>\n",
915 eval.target_path, safe_diff
916 ));
917 }
918 }
919
920 out
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(rename_all = "kebab-case")]
925pub enum ResolutionChoice {
926 Ours,
927 Theirs,
928}
929
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931pub struct ResolvedFileRecord {
932 pub target_name: String,
933 pub target_path: String,
934 pub choice: ResolutionChoice,
935 pub dry_run: bool,
936}
937
938pub fn resolve_target_file(
939 source_root: &Path,
940 target: &TargetRepo,
941 file: &ManagedFile,
942 global_vars: &BTreeMap<String, String>,
943 upstream_revision: Option<&str>,
944 choice: ResolutionChoice,
945 write: bool,
946) -> Result<ResolvedFileRecord, DriftError> {
947 let src_rel = validate_relative_path(&file.source)?;
948 let tgt_rel = validate_relative_path(&file.target)?;
949
950 let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
951 let raw_theirs = read_bounded_file(&src_full)?;
952 if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
953 return Err(DriftError::SecretDetected {
954 location: file.source.clone(),
955 secret_kind,
956 });
957 }
958 let rendered_theirs = render_template(&raw_theirs, target, global_vars, &src_full)?;
959 if let Some(secret_kind) = scan_for_hardcoded_secrets(&rendered_theirs) {
960 return Err(DriftError::SecretDetected {
961 location: format!("{} (target: {})", file.source, target.name),
962 secret_kind,
963 });
964 }
965 let theirs_norm = normalize_newlines(&rendered_theirs);
966
967 let repo_rel = validate_relative_path(&target.effective_path())?;
968 let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
969 let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
970
971 if write {
972 if choice == ResolutionChoice::Theirs {
973 if let Some(parent) = tgt_full.parent() {
974 fs::create_dir_all(parent).map_err(|source| DriftError::Io {
975 path: parent.to_path_buf(),
976 source,
977 })?;
978 }
979 fs::write(&tgt_full, &theirs_norm).map_err(|source| DriftError::Io {
980 path: tgt_full,
981 source,
982 })?;
983 }
984
985 let mut lock = LockFile::load_optional(&repo_root)?;
986 lock.files.insert(
987 file.target.clone(),
988 LockedFileEntry {
989 base_hash: hash_content(&theirs_norm),
990 base_content: theirs_norm,
991 },
992 );
993 if let Some(rev) = upstream_revision {
994 lock.upstream_revision = Some(rev.to_string());
995 }
996 fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
997 path: repo_root.clone(),
998 source,
999 })?;
1000 lock.save(&repo_root)?;
1001 }
1002
1003 Ok(ResolvedFileRecord {
1004 target_name: target.name.clone(),
1005 target_path: file.target.clone(),
1006 choice,
1007 dry_run: !write,
1008 })
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use proptest::prelude::*;
1015
1016 #[test]
1017 fn rejects_unsafe_paths() {
1018 assert!(validate_relative_path("../secret").is_err());
1019 assert!(validate_relative_path("a/../../b").is_err());
1020 assert!(validate_relative_path("/etc/passwd").is_err());
1021 assert!(validate_relative_path("C:\\Windows\\win.ini").is_err());
1022 assert!(validate_relative_path("").is_err());
1023
1024 assert!(validate_relative_path(".git/config").is_err());
1025 assert!(validate_relative_path(".GIT/hooks/pre-commit").is_err());
1026 assert!(validate_relative_path("nested/.git/hooks/post-checkout").is_err());
1027 assert!(validate_relative_path(".driftsync.lock").is_err());
1028 assert!(validate_relative_path("CON").is_err());
1029 assert!(validate_relative_path("dir/NUL.txt").is_err());
1030 assert!(validate_relative_path("COM1.json").is_err());
1031 assert!(validate_relative_path("LPT9").is_err());
1032 assert!(validate_relative_path("trailing./file.txt").is_err());
1033
1034 assert!(validate_relative_path(".github/workflows/ci.yml").is_ok());
1035 assert!(validate_relative_path(".editorconfig").is_ok());
1036 }
1037
1038 #[test]
1039 fn rejects_invalid_target_names() {
1040 assert!(validate_target_name("service-auth").is_ok());
1041 assert!(validate_target_name("web_dashboard_01").is_ok());
1042
1043 assert!(validate_target_name("").is_err());
1044 assert!(validate_target_name("-flag-injection").is_err());
1045 assert!(validate_target_name(".hidden").is_err());
1046 assert!(validate_target_name("../escape").is_err());
1047 assert!(validate_target_name("org/repo").is_err());
1048 assert!(validate_target_name("CON").is_err());
1049 assert!(validate_target_name("with space").is_err());
1050 }
1051
1052 #[test]
1053 fn secret_scanner_blocks_hardcoded_tokens_and_private_keys() {
1054 let ghp = format!("ghp_{}", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8");
1055 assert_eq!(
1056 scan_for_hardcoded_secrets(&format!("token = \"{ghp}\"")),
1057 Some("GitHub Personal Access Token")
1058 );
1059
1060 let gh_pat = format!("github_pat_{}", "11AABBCCDD0123456789_abcdefghijklmnop");
1061 assert_eq!(
1062 scan_for_hardcoded_secrets(&gh_pat),
1063 Some("GitHub Fine-Grained PAT")
1064 );
1065
1066 let glpat = format!("glpat-{}", "1234567890abcdefghij");
1067 assert_eq!(
1068 scan_for_hardcoded_secrets(&glpat),
1069 Some("GitLab Personal Access Token")
1070 );
1071
1072 let aws = format!("AKIA{}", "IOSFODNN7EXAMPLE");
1073 assert_eq!(scan_for_hardcoded_secrets(&aws), Some("AWS Access Key ID"));
1074
1075 let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA...\n";
1076 assert_eq!(scan_for_hardcoded_secrets(pem), Some("PEM Private Key"));
1077
1078 assert_eq!(
1079 scan_for_hardcoded_secrets("token: ${{ secrets.GITHUB_TOKEN }}"),
1080 None
1081 );
1082
1083 let mut bad_vars = BTreeMap::new();
1084 bad_vars.insert("api_token".to_string(), ghp);
1085 let manifest = Manifest {
1086 version: 1,
1087 vars: bad_vars,
1088 targets: vec![],
1089 files: vec![],
1090 };
1091 let err = manifest.validate().expect_err("secret in vars must fail");
1092 assert!(matches!(err, DriftError::SecretDetected { .. }));
1093 }
1094
1095 #[test]
1096 fn redacts_sensitive_urls_and_tokens_in_diffs_and_markdown() {
1097 let ghp = format!("ghp_{}", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8");
1098 let ours = "registry = \"https://admin:superSecretPass@npm.example.com/private\"\n";
1099 let theirs = format!("registry = \"https://ci:{ghp}@npm.example.com/private\"\n");
1100
1101 let eval = evaluate_file(
1102 "templates/.npmrc",
1103 ".npmrc",
1104 SyncStrategy::ThreeWay,
1105 Some(ours),
1106 Some(ours),
1107 &theirs,
1108 );
1109
1110 assert!(!eval.unified_diff.contains("superSecretPass"));
1111 assert!(!eval.unified_diff.contains(&ghp));
1112 assert!(
1113 eval.unified_diff
1114 .contains("https://[REDACTED]@npm.example.com/private")
1115 );
1116
1117 let report = RepoReport {
1118 target_name: "web-app".to_string(),
1119 target_path: "repos/web-app".to_string(),
1120 evaluations: vec![eval],
1121 };
1122 let md = generate_markdown_report(&[report]);
1123 assert!(!md.contains("superSecretPass"));
1124 assert!(!md.contains(&ghp));
1125 }
1126
1127 #[test]
1128 fn bounded_file_reader_enforces_size_limit() {
1129 let tmp = tempfile::tempdir().expect("tempdir");
1130 let oversized = tmp.path().join("huge.toml");
1131 let f = fs::File::create(&oversized).expect("create");
1132 f.set_len(MAX_MANAGED_FILE_BYTES + 1024).expect("set_len");
1133
1134 let err = read_bounded_file(&oversized).expect_err("must reject oversized file");
1135 assert!(matches!(err, DriftError::FileTooLarge { .. }));
1136 }
1137
1138 #[test]
1139 fn template_rendering_interpolates_target_and_vars_while_preserving_github_actions() {
1140 let mut global_vars = BTreeMap::new();
1141 global_vars.insert("rust_version".to_string(), "1.85".to_string());
1142 global_vars.insert("port".to_string(), "8080".to_string());
1143
1144 let mut target_vars = BTreeMap::new();
1145 target_vars.insert("port".to_string(), "9091".to_string());
1146
1147 let target = TargetRepo {
1148 name: "service-auth".to_string(),
1149 path: "repos/service-auth".to_string(),
1150 repo: None,
1151 vars: target_vars,
1152 };
1153
1154 let tpl = r#"name: CI ({{ target.name }})
1155env:
1156 SERVICE_NAME: "{{target.name}}"
1157 SERVICE_PATH: "{{ target.path }}"
1158 PORT: "{{ vars.port }}"
1159 RUST_VERSION: "{{ vars.rust_version }}"
1160 GH_SECRET: "${{ secrets.API_TOKEN }}"
1161 HELM_EXPR: "{{ .Values.image }}"
1162"#;
1163
1164 let rendered =
1165 render_template(tpl, &target, &global_vars, Path::new("templates/ci.yml")).expect("ok");
1166 assert!(rendered.contains("name: CI (service-auth)"));
1167 assert!(rendered.contains("SERVICE_NAME: \"service-auth\""));
1168 assert!(rendered.contains("SERVICE_PATH: \"repos/service-auth\""));
1169 assert!(rendered.contains("PORT: \"9091\""));
1170 assert!(rendered.contains("RUST_VERSION: \"1.85\""));
1171 assert!(rendered.contains("GH_SECRET: \"${{ secrets.API_TOKEN }}\""));
1172 assert!(rendered.contains("HELM_EXPR: \"{{ .Values.image }}\""));
1173 }
1174
1175 #[test]
1176 fn template_rendering_rejects_undefined_target_or_vars_keys() {
1177 let global_vars = BTreeMap::new();
1178 let target = TargetRepo {
1179 name: "service-auth".to_string(),
1180 path: "repos/service-auth".to_string(),
1181 repo: None,
1182 vars: BTreeMap::new(),
1183 };
1184
1185 let err1 = render_template(
1186 "service: {{ target.unknown }}",
1187 &target,
1188 &global_vars,
1189 Path::new("templates/ci.yml"),
1190 )
1191 .expect_err("should fail on unknown target field");
1192 assert!(matches!(err1, DriftError::UndefinedTemplateVar { .. }));
1193
1194 let err2 = render_template(
1195 "port: {{ vars.missing_port }}",
1196 &target,
1197 &global_vars,
1198 Path::new("templates/ci.yml"),
1199 )
1200 .expect_err("should fail on missing var");
1201 assert!(matches!(err2, DriftError::UndefinedTemplateVar { .. }));
1202 }
1203
1204 #[test]
1205 fn three_way_merge_preserves_local_edits_when_non_overlapping() {
1206 let base = "line1\nline2\nline3\nline4\n";
1207 let ours = "line1\nline2-local-custom\nline3\nline4\n";
1208 let theirs = "line1\nline2\nline3\nline4-upstream-update\n";
1209
1210 let eval = evaluate_file(
1211 "templates/cfg",
1212 "cfg",
1213 SyncStrategy::ThreeWay,
1214 Some(base),
1215 Some(ours),
1216 theirs,
1217 );
1218
1219 assert_eq!(eval.status, DriftStatus::BothChangedClean);
1220 assert_eq!(
1221 eval.plan,
1222 PlanAction::MergeClean {
1223 merged_content: "line1\nline2-local-custom\nline3\nline4-upstream-update\n"
1224 .to_string(),
1225 new_base_content: theirs.to_string(),
1226 }
1227 );
1228 }
1229
1230 #[test]
1231 fn conflicting_edits_produce_conflict_without_overwriting() {
1232 let base = "timeout = 30\n";
1233 let ours = "timeout = 60\n";
1234 let theirs = "timeout = 15\n";
1235
1236 let eval = evaluate_file(
1237 "templates/cfg.toml",
1238 "cfg.toml",
1239 SyncStrategy::ThreeWay,
1240 Some(base),
1241 Some(ours),
1242 theirs,
1243 );
1244
1245 assert_eq!(eval.status, DriftStatus::BothChangedConflict);
1246 match &eval.plan {
1247 PlanAction::Conflict { conflict_preview } => {
1248 assert!(conflict_preview.contains("timeout"));
1249 assert!(conflict_preview.contains("60"));
1250 assert!(conflict_preview.contains("15"));
1251 }
1252 other => panic!("unexpected plan: {other:?}"),
1253 }
1254 }
1255
1256 #[test]
1257 fn toml_structural_merge_resolves_adjacent_keys_and_preserves_comments() {
1258 let base = r#"# Shared Rust settings
1259[package]
1260name = "service-a"
1261edition = "2021"
1262
1263[dependencies]
1264serde = "1.0"
1265"#;
1266 let ours = r#"# Shared Rust settings
1267[package]
1268name = "service-a"
1269edition = "2021" # local note
1270publish = false
1271
1272[dependencies]
1273# Database driver
1274sqlx = "0.8"
1275serde = "1.0"
1276"#;
1277 let theirs = r#"# Shared Rust settings
1278[package]
1279name = "service-a"
1280edition = "2024"
1281rust-version = "1.85"
1282
1283[dependencies]
1284serde = "1.0"
1285tracing = "0.1"
1286"#;
1287
1288 let eval = evaluate_file(
1289 "templates/Cargo.toml",
1290 "Cargo.toml",
1291 SyncStrategy::ThreeWay,
1292 Some(base),
1293 Some(ours),
1294 theirs,
1295 );
1296
1297 assert_eq!(eval.status, DriftStatus::BothChangedClean);
1298 match &eval.plan {
1299 PlanAction::MergeClean { merged_content, .. } => {
1300 assert!(merged_content.contains("# Shared Rust settings"));
1301 assert!(merged_content.contains("# Database driver"));
1302 assert!(merged_content.contains("edition = \"2024\" # local note"));
1303 assert!(merged_content.contains("publish = false"));
1304 assert!(merged_content.contains("rust-version = \"1.85\""));
1305 assert!(merged_content.contains("sqlx = \"0.8\""));
1306 assert!(merged_content.contains("tracing = \"0.1\""));
1307 }
1308 other => panic!("unexpected plan: {other:?}"),
1309 }
1310 }
1311
1312 #[test]
1313 fn toml_structural_merge_handles_inline_tables_recursively() {
1314 let base = "[dependencies]\ntokio = { version = \"1.38\", features = [\"rt\"] }\n";
1315 let ours = "[dependencies]\ntokio = { version = \"1.38\", features = [\"rt\", \"net\"] }\n";
1316 let theirs = "[dependencies]\ntokio = { version = \"1.40\", features = [\"rt\"] }\n";
1317
1318 let merged = merge_toml_structural(base, ours, theirs).expect("inline table merge");
1319 let doc: toml::Value = toml::from_str(&merged).expect("valid toml");
1320 assert_eq!(
1321 doc["dependencies"]["tokio"]["version"].as_str(),
1322 Some("1.40")
1323 );
1324 let features = doc["dependencies"]["tokio"]["features"]
1325 .as_array()
1326 .expect("features array");
1327 assert_eq!(features.len(), 2);
1328 }
1329
1330 #[test]
1331 fn json_structural_merge_combines_nested_objects_cleanly() {
1332 let base = r#"{
1333 "name": "web-app",
1334 "scripts": {
1335 "build": "tsc",
1336 "test": "vitest"
1337 }
1338}
1339"#;
1340 let ours = r#"{
1341 "name": "web-app",
1342 "scripts": {
1343 "build": "tsc",
1344 "dev": "vite",
1345 "test": "vitest"
1346 }
1347}
1348"#;
1349 let theirs = r#"{
1350 "name": "web-app",
1351 "scripts": {
1352 "build": "tsc",
1353 "lint": "eslint .",
1354 "test": "vitest run"
1355 }
1356}
1357"#;
1358
1359 let eval = evaluate_file(
1360 "templates/package.json",
1361 "package.json",
1362 SyncStrategy::ThreeWay,
1363 Some(base),
1364 Some(ours),
1365 theirs,
1366 );
1367
1368 assert_eq!(eval.status, DriftStatus::BothChangedClean);
1369 match &eval.plan {
1370 PlanAction::MergeClean { merged_content, .. } => {
1371 let parsed: serde_json::Value =
1372 serde_json::from_str(merged_content).expect("valid json");
1373 assert_eq!(parsed["scripts"]["dev"], "vite");
1374 assert_eq!(parsed["scripts"]["lint"], "eslint .");
1375 assert_eq!(parsed["scripts"]["test"], "vitest run");
1376 }
1377 other => panic!("unexpected plan: {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn yaml_structural_merge_combines_adjacent_keys_and_steps_without_conflict() {
1383 let base = r#"# CI Workflow
1384name: CI
1385on: [push]
1386jobs:
1387 test:
1388 runs-on: ubuntu-latest
1389 steps:
1390 - uses: actions/checkout@v4
1391 - run: cargo test --locked
1392"#;
1393 let ours = r#"# CI Workflow
1394name: CI
1395on: [push]
1396jobs:
1397 test:
1398 runs-on: ubuntu-latest
1399 permissions: read-all
1400 steps:
1401 - uses: actions/checkout@v4
1402 - run: cargo test --locked
1403 - run: ./scripts/verify-pci.sh
1404"#;
1405 let theirs = r#"# CI Workflow
1406name: CI
1407on: [push]
1408jobs:
1409 test:
1410 runs-on: ubuntu-24.04
1411 timeout-minutes: 20
1412 steps:
1413 - uses: actions/checkout@v4
1414 - run: cargo test --locked
1415 - run: cargo fmt --all -- --check
1416"#;
1417
1418 let eval = evaluate_file(
1419 "templates/ci.yml",
1420 ".github/workflows/ci.yml",
1421 SyncStrategy::ThreeWay,
1422 Some(base),
1423 Some(ours),
1424 theirs,
1425 );
1426
1427 assert_eq!(eval.status, DriftStatus::BothChangedClean);
1428 match &eval.plan {
1429 PlanAction::MergeClean { merged_content, .. } => {
1430 assert!(merged_content.contains("# CI Workflow"));
1431 assert!(merged_content.contains("runs-on: ubuntu-24.04"));
1432 assert!(merged_content.contains("permissions: read-all"));
1433 assert!(merged_content.contains("timeout-minutes: 20"));
1434 assert!(merged_content.contains("cargo fmt --all -- --check"));
1435 assert!(merged_content.contains("./scripts/verify-pci.sh"));
1436 }
1437 other => panic!("unexpected plan: {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1442 fn markdown_report_and_pr_body_and_audit_check_work() {
1443 let eval = evaluate_file(
1444 "templates/.editorconfig",
1445 ".editorconfig",
1446 SyncStrategy::ThreeWay,
1447 Some("root = true\n"),
1448 Some("root = true\n"),
1449 "root = true\nindent_size = 4\n",
1450 );
1451 let report = RepoReport {
1452 target_name: "service-auth".to_string(),
1453 target_path: "repos/service-auth".to_string(),
1454 evaluations: vec![eval],
1455 };
1456
1457 assert!(has_actionable_drift(std::slice::from_ref(&report)));
1458 let md = generate_markdown_report(std::slice::from_ref(&report));
1459 assert!(md.contains("service-auth"));
1460 assert!(md.contains("UPSTREAM-CHANGED"));
1461
1462 let pr_body = generate_pr_body(&report, Some("abc1234"));
1463 assert!(pr_body.contains("abc1234"));
1464 assert!(pr_body.contains(".editorconfig"));
1465 }
1466
1467 proptest! {
1468 #[test]
1469 fn prop_local_changes_never_overwritten_when_upstream_unchanged(
1470 base in "[a-zA-Z0-9\n]{0,80}",
1471 ours in "[a-zA-Z0-9\n]{0,80}",
1472 ) {
1473 let eval = evaluate_file(
1474 "src.txt",
1475 "dst.txt",
1476 SyncStrategy::ThreeWay,
1477 Some(&base),
1478 Some(&ours),
1479 &base,
1480 );
1481 let preserves_local = matches!(eval.plan, PlanAction::NoOp | PlanAction::KeepLocal);
1482 prop_assert!(preserves_local, "unexpected plan: {:?}", eval.plan);
1483 }
1484
1485 #[test]
1486 fn prop_fast_forward_when_target_unmodified(
1487 base in "[a-zA-Z0-9\n]{0,80}",
1488 theirs in "[a-zA-Z0-9\n]{0,80}",
1489 ) {
1490 let eval = evaluate_file(
1491 "src.txt",
1492 "dst.txt",
1493 SyncStrategy::ThreeWay,
1494 Some(&base),
1495 Some(&base),
1496 &theirs,
1497 );
1498 let expected_theirs = normalize_newlines(&theirs);
1499 let expected_base = normalize_newlines(&base);
1500 if expected_base == expected_theirs {
1501 prop_assert_eq!(eval.status, DriftStatus::InSync);
1502 prop_assert_eq!(eval.plan, PlanAction::NoOp);
1503 } else {
1504 prop_assert_eq!(eval.status, DriftStatus::UpstreamChanged);
1505 prop_assert_eq!(
1506 eval.plan,
1507 PlanAction::UpdateFastForward {
1508 new_content: expected_theirs
1509 }
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn prop_idempotent_after_apply(
1516 base in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1517 ours in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1518 theirs in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1519 ) {
1520 let first = evaluate_file(
1521 "src.txt",
1522 "dst.txt",
1523 SyncStrategy::ThreeWay,
1524 Some(&base),
1525 Some(&ours),
1526 &theirs,
1527 );
1528
1529 let (next_base, next_ours) = match &first.plan {
1530 PlanAction::Create { new_content }
1531 | PlanAction::UpdateFastForward { new_content }
1532 | PlanAction::OverwriteWithUpstream { new_content } => {
1533 (new_content.clone(), new_content.clone())
1534 }
1535 PlanAction::MergeClean { merged_content, new_base_content } => {
1536 (new_base_content.clone(), merged_content.clone())
1537 }
1538 PlanAction::RecordLock { base_content } => {
1539 (base_content.clone(), normalize_newlines(&ours))
1540 }
1541 PlanAction::NoOp | PlanAction::KeepLocal | PlanAction::NeedsAdopt | PlanAction::Conflict { .. } => {
1542 return Ok(());
1543 }
1544 };
1545
1546 let second = evaluate_file(
1547 "src.txt",
1548 "dst.txt",
1549 SyncStrategy::ThreeWay,
1550 Some(&next_base),
1551 Some(&next_ours),
1552 &theirs,
1553 );
1554
1555 let no_second_write = matches!(second.plan, PlanAction::NoOp | PlanAction::KeepLocal);
1556 prop_assert!(no_second_write, "second evaluation produced write: {:?}", second);
1557 }
1558
1559 #[test]
1560 fn prop_toml_disjoint_keys_always_merge_cleanly(
1561 local_key in "local_[a-z]{1,8}",
1562 upstream_key in "upstream_[a-z]{1,8}",
1563 local_val in 1i64..10_000,
1564 upstream_val in 1i64..10_000,
1565 ) {
1566 let base = "[settings]\nshared = 1\n";
1567 let ours = format!("[settings]\nshared = 1\n{local_key} = {local_val}\n");
1568 let theirs = format!("[settings]\nshared = 1\n{upstream_key} = {upstream_val}\n");
1569
1570 let eval = evaluate_file(
1571 "templates/config.toml",
1572 "config.toml",
1573 SyncStrategy::ThreeWay,
1574 Some(base),
1575 Some(&ours),
1576 &theirs,
1577 );
1578
1579 prop_assert_eq!(eval.status, DriftStatus::BothChangedClean);
1580 if let PlanAction::MergeClean { merged_content, .. } = eval.plan {
1581 let parsed: toml::Value = toml::from_str(&merged_content).expect("valid merged toml");
1582 prop_assert_eq!(parsed["settings"][&local_key].as_integer(), Some(local_val));
1583 prop_assert_eq!(parsed["settings"][&upstream_key].as_integer(), Some(upstream_val));
1584 prop_assert_eq!(parsed["settings"]["shared"].as_integer(), Some(1));
1585 } else {
1586 prop_assert!(false, "expected MergeClean");
1587 }
1588 }
1589
1590 #[test]
1591 fn prop_json_disjoint_keys_always_merge_cleanly(
1592 local_key in "local_[a-z]{1,8}",
1593 upstream_key in "upstream_[a-z]{1,8}",
1594 local_val in 1i64..10_000,
1595 upstream_val in 1i64..10_000,
1596 ) {
1597 let base = "{\"settings\":{\"shared\":1}}\n";
1598 let ours = format!("{{\"settings\":{{\"shared\":1,\"{local_key}\":{local_val}}}}}\n");
1599 let theirs = format!("{{\"settings\":{{\"shared\":1,\"{upstream_key}\":{upstream_val}}}}}\n");
1600
1601 let eval = evaluate_file(
1602 "templates/config.json",
1603 "config.json",
1604 SyncStrategy::ThreeWay,
1605 Some(base),
1606 Some(&ours),
1607 &theirs,
1608 );
1609
1610 prop_assert_eq!(eval.status, DriftStatus::BothChangedClean);
1611 if let PlanAction::MergeClean { merged_content, .. } = eval.plan {
1612 let parsed: serde_json::Value = serde_json::from_str(&merged_content).expect("valid merged json");
1613 prop_assert_eq!(parsed["settings"][&local_key].as_i64(), Some(local_val));
1614 prop_assert_eq!(parsed["settings"][&upstream_key].as_i64(), Some(upstream_val));
1615 prop_assert_eq!(parsed["settings"]["shared"].as_i64(), Some(1));
1616 } else {
1617 prop_assert!(false, "expected MergeClean");
1618 }
1619 }
1620 }
1621}