1use super::json_record::quote;
18use super::private_storage::{sync_directory, write_exclusive_synced};
19use super::random_id::random_uuid;
20use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
21use super::worktree_copy_install_entry::InstalledPath;
22use super::worktree_copy_selection::{STAGE_PREFIX, assert_safe_repository_path, js_order};
23use super::worktree_copy_stage::StagedSnapshot;
24use std::path::{Path, PathBuf};
25
26pub const JOURNAL_PARENT_NAME: &str = "cli-git-worktree-copy";
36
37pub const JOURNAL_VERSION_NAME: &str = "v1";
47
48const JOURNAL_SUFFIX: &str = ".json";
58
59const TEMPORARY_SUFFIX: &str = ".tmp";
69
70const NON_PRIVATE_MODE_BITS: u32 = 0o077;
80
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum Phase {
92 Staged,
94 Installing,
96 Complete,
98}
99
100impl Phase {
101 fn as_str(self) -> &'static str {
103 match self {
105 Phase::Staged => return "staged",
106 Phase::Installing => return "installing",
107 Phase::Complete => return "complete",
108 }
109 }
110}
111
112#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct Journal {
123 pub created_entries: Vec<InstalledPath>,
125 pub intended_entries: Vec<String>,
127 pub destination_root: String,
129 pub phase: Phase,
131 pub selected_roots: Vec<String>,
133 pub source_root: String,
135 pub stage_container: String,
137 pub stage_root: String,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct PendingJournal {
152 pub path: PathBuf,
154 pub journal: Journal,
156}
157
158pub fn encode_journal(journal: &Journal) -> String {
168 let created: Vec<String> = journal
169 .created_entries
170 .iter()
171 .map(encode_installed_entry)
172 .collect();
173 return format!(
174 "{{\"createdEntries\":[{}],\"intendedEntries\":{},\"destinationRoot\":{},\"phase\":{},\"selectedRoots\":{},\"sourceRoot\":{},\"stageContainer\":{},\"stageRoot\":{},\"version\":1}}\n",
175 created.join(","),
176 string_array(journal.intended_entries.as_slice()),
177 quote(journal.destination_root.as_str()),
178 quote(journal.phase.as_str()),
179 string_array(journal.selected_roots.as_slice()),
180 quote(journal.source_root.as_str()),
181 quote(journal.stage_container.as_str()),
182 quote(journal.stage_root.as_str()),
183 );
184}
185
186pub fn encode_installed_entry(entry: &InstalledPath) -> String {
196 return format!(
197 "{{\"device\":\"{}\",\"inode\":\"{}\",\"relativePath\":{},\"selected\":{}}}",
198 entry.device,
199 entry.inode,
200 quote(entry.relative_path.as_str()),
201 entry.selected
202 );
203}
204
205pub fn string_array(items: &[String]) -> String {
215 let quoted: Vec<String> = items
216 .iter()
217 .map(|item| return quote(item.as_str()))
218 .collect();
219 return format!("[{}]", quoted.join(","));
220}
221
222fn is_canonical_absolute(value: &str) -> bool {
232 if !value.starts_with('/') {
233 return false;
234 }
235 if value == "/" {
236 return true;
237 }
238 return value[1..]
239 .split('/')
240 .all(|component| return !component.is_empty() && component != "." && component != "..");
241}
242
243fn decimal_identity(value: &serde_json::Value) -> Option<u64> {
253 let text: &str = value.as_str()?;
254 if text.is_empty() || (text.len() > 1 && text.starts_with('0')) {
255 return None;
256 }
257 if !text.bytes().all(|byte| return byte.is_ascii_digit()) {
258 return None;
259 }
260 return text.parse::<u64>().ok();
261}
262
263pub fn installed_entry(value: &serde_json::Value) -> Option<InstalledPath> {
273 let object = value.as_object()?;
274 return Some(InstalledPath {
275 device: decimal_identity(object.get("device")?)?,
276 inode: decimal_identity(object.get("inode")?)?,
277 relative_path: object.get("relativePath")?.as_str()?.to_string(),
278 selected: object.get("selected")?.as_bool()?,
279 });
280}
281
282fn string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
292 let array = value?.as_array()?;
293 return array
294 .iter()
295 .map(|item| return item.as_str().map(str::to_string))
296 .collect();
297}
298
299fn read_shape(value: &serde_json::Value) -> Option<Journal> {
309 let object = value.as_object()?;
310 if object.get("version")?.as_f64()? != 1.0 {
311 return None;
312 }
313 let phase: Phase = match object.get("phase")?.as_str()? {
314 "staged" => Phase::Staged,
315 "installing" => Phase::Installing,
316 "complete" => Phase::Complete,
317 _ => return None,
318 };
319 let created_entries: Vec<InstalledPath> = object
320 .get("createdEntries")?
321 .as_array()?
322 .iter()
323 .map(installed_entry)
324 .collect::<Option<Vec<InstalledPath>>>()?;
325 return Some(Journal {
326 created_entries,
327 intended_entries: string_list(object.get("intendedEntries"))?,
328 destination_root: object.get("destinationRoot")?.as_str()?.to_string(),
329 phase,
330 selected_roots: string_list(object.get("selectedRoots"))?,
331 source_root: object.get("sourceRoot")?.as_str()?.to_string(),
332 stage_container: object.get("stageContainer")?.as_str()?.to_string(),
333 stage_root: object.get("stageRoot")?.as_str()?.to_string(),
334 });
335}
336
337pub fn parse_journal(
347 text: &str,
348 path: &Path,
349 journal_root: &Path,
350) -> Result<Journal, WorktreeCopyError> {
351 let value: serde_json::Value = serde_json::from_str(text).map_err(|error| {
352 let message: String = format!(
353 "cli-git: could not read worktree-copy journal {}.",
354 quote(path.to_string_lossy().as_ref())
355 );
356 return failure_with_cause(message.as_str(), Some(&error));
357 })?;
358 let Some(journal) = read_shape(&value) else {
359 return Err(corrupt(path));
360 };
361 if path.parent() != Some(journal_root) {
362 return Err(failure_with_cause(
363 format!(
364 "cli-git: worktree-copy journal escaped private root: {}.",
365 quote(path.to_string_lossy().as_ref())
366 )
367 .as_str(),
368 None,
369 ));
370 }
371 for (field, field_value) in [
372 ("destinationRoot", journal.destination_root.as_str()),
373 ("sourceRoot", journal.source_root.as_str()),
374 ("stageContainer", journal.stage_container.as_str()),
375 ("stageRoot", journal.stage_root.as_str()),
376 ] {
377 if !is_canonical_absolute(field_value) {
378 return Err(failure_with_cause(
379 format!(
380 "cli-git: worktree-copy journal has unsafe {field}: {}.",
381 quote(field_value)
382 )
383 .as_str(),
384 None,
385 ));
386 }
387 }
388 let stage_relation: bool = Path::new(journal.stage_container.as_str()).parent()
389 == Path::new(journal.destination_root.as_str()).parent()
390 && Path::new(journal.stage_container.as_str())
391 .file_name()
392 .is_some_and(|name| return name.to_string_lossy().starts_with(STAGE_PREFIX))
393 && journal.stage_root == format!("{}/payload", journal.stage_container);
394 if !stage_relation {
395 return Err(failure_with_cause(
396 format!(
397 "cli-git: worktree-copy journal has unsafe private stage relation: {}.",
398 quote(path.to_string_lossy().as_ref())
399 )
400 .as_str(),
401 None,
402 ));
403 }
404 for selected in &journal.selected_roots {
405 assert_safe_repository_path(selected)?;
406 }
407 for intended in &journal.intended_entries {
408 assert_safe_repository_path(intended)?;
409 }
410 for created in &journal.created_entries {
411 assert_safe_repository_path(created.relative_path.as_str())?;
412 }
413 let created_paths: Vec<&str> = journal
414 .created_entries
415 .iter()
416 .map(|entry| return entry.relative_path.as_str())
417 .collect();
418 let duplicated: bool = has_duplicates(journal.selected_roots.as_slice())
419 || has_duplicates(journal.intended_entries.as_slice())
420 || has_duplicates(created_paths.as_slice());
421 if duplicated {
422 return Err(failure_with_cause(
423 format!(
424 "cli-git: worktree-copy journal contains duplicate repository paths: {}.",
425 quote(path.to_string_lossy().as_ref())
426 )
427 .as_str(),
428 None,
429 ));
430 }
431 return Ok(journal);
432}
433
434fn has_duplicates<T: AsRef<str>>(items: &[T]) -> bool {
444 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
445 return items.iter().any(|item| return !seen.insert(item.as_ref()));
446}
447
448fn corrupt(path: &Path) -> WorktreeCopyError {
458 return failure_with_cause(
459 format!(
460 "cli-git: worktree-copy journal is corrupt: {}.",
461 quote(path.to_string_lossy().as_ref())
462 )
463 .as_str(),
464 None,
465 );
466}
467
468#[derive(Clone, Copy, Debug, Eq, PartialEq)]
478pub enum Role {
479 JournalDirectory,
481 JournalFile,
483 PrivateStage,
485}
486
487impl Role {
488 fn name(self) -> &'static str {
490 match self {
492 Role::JournalDirectory => return "journal directory",
493 Role::JournalFile => return "journal file",
494 Role::PrivateStage => return "private stage",
495 }
496 }
497}
498
499pub fn assert_private_path(path: &Path, role: Role) -> Result<(), WorktreeCopyError> {
509 use std::os::unix::fs::MetadataExt;
510 let metadata: std::fs::Metadata =
511 std::fs::symlink_metadata(path).map_err(|error| return raw_failure(&error))?;
512 let shape: bool = match role {
513 Role::JournalFile => metadata.is_file() && metadata.nlink() == 1,
514 Role::JournalDirectory | Role::PrivateStage => metadata.is_dir(),
515 };
516 let effective_user: u32 = unsafe { libc::geteuid() };
518 let canonical: bool = std::fs::canonicalize(path)
519 .map_err(|error| return raw_failure(&error))?
520 .as_path()
521 == path;
522 if !shape
523 || (metadata.mode() & NON_PRIVATE_MODE_BITS) != 0
524 || metadata.uid() != effective_user
525 || !canonical
526 {
527 return Err(failure_with_cause(
528 format!(
529 "cli-git: worktree-copy {} is unsafe: {}.",
530 role.name(),
531 quote(path.to_string_lossy().as_ref())
532 )
533 .as_str(),
534 None,
535 ));
536 }
537 return Ok(());
538}
539
540pub fn journal_root(common_dir: &Path) -> PathBuf {
550 return common_dir
551 .join(JOURNAL_PARENT_NAME)
552 .join(JOURNAL_VERSION_NAME);
553}
554
555fn ensure_private_directory(path: &Path) -> Result<(), WorktreeCopyError> {
565 use std::os::unix::fs::DirBuilderExt;
566 match std::fs::DirBuilder::new().mode(0o700).create(path) {
568 Ok(()) => {}
569 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
570 Err(error) => return Err(raw_failure(&error)),
571 }
572 return assert_private_path(path, Role::JournalDirectory);
573}
574
575pub fn ensure_journal_root(common_dir: &Path) -> Result<PathBuf, WorktreeCopyError> {
585 ensure_private_directory(common_dir.join(JOURNAL_PARENT_NAME).as_path())?;
586 let root: PathBuf = journal_root(common_dir);
587 ensure_private_directory(root.as_path())?;
588 return Ok(root);
589}
590
591pub fn write_journal(path: &Path, journal: &Journal) -> Result<(), WorktreeCopyError> {
601 let unique: String = random_uuid().map_err(|error| {
602 return failure_with_cause(
603 format!(
604 "cli-git: could not persist worktree-copy journal {}.",
605 quote(path.to_string_lossy().as_ref())
606 )
607 .as_str(),
608 Some(&error.0),
609 );
610 })?;
611 let temporary: PathBuf = PathBuf::from(format!(
612 "{}.{unique}{TEMPORARY_SUFFIX}",
613 path.to_string_lossy()
614 ));
615 let persisted: std::io::Result<()> =
616 write_exclusive_synced(temporary.as_path(), encode_journal(journal).as_bytes())
617 .and_then(|()| return std::fs::rename(temporary.as_path(), path))
618 .and_then(|()| match path.parent() {
619 Some(parent) => return sync_directory(parent),
620 None => return Ok(()),
621 });
622 if let Err(error) = persisted {
624 let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
625 return Err(failure_with_cause(
626 format!(
627 "cli-git: could not persist worktree-copy journal {}.",
628 quote(path.to_string_lossy().as_ref())
629 )
630 .as_str(),
631 Some(&error),
632 ));
633 }
634 return Ok(());
635}
636
637pub fn create_journal(
647 common_dir: &Path,
648 destination_root: &Path,
649 snapshot: &StagedSnapshot,
650) -> Result<PendingJournal, WorktreeCopyError> {
651 let root: PathBuf = ensure_journal_root(common_dir)?;
652 let unique: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
653 let path: PathBuf = root.join(format!("{unique}{JOURNAL_SUFFIX}"));
654 let journal: Journal = Journal {
655 created_entries: Vec::new(),
656 intended_entries: Vec::new(),
657 destination_root: destination_root.to_string_lossy().into_owned(),
658 phase: Phase::Staged,
659 selected_roots: snapshot.selected_roots.clone(),
660 source_root: snapshot.source_root.to_string_lossy().into_owned(),
661 stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
662 stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
663 };
664 write_journal(path.as_path(), &journal)?;
665 return Ok(PendingJournal { path, journal });
666}
667
668pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
678 let root: PathBuf = journal_root(common_dir);
679 match std::fs::symlink_metadata(root.as_path()) {
681 Ok(_) => {}
682 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
683 Err(error) => return Err(raw_failure(&error)),
684 }
685 assert_private_path(
686 root.parent().unwrap_or(root.as_path()),
687 Role::JournalDirectory,
688 )?;
689 assert_private_path(root.as_path(), Role::JournalDirectory)?;
690 let mut names: Vec<String> = Vec::new();
691 let listing = std::fs::read_dir(root.as_path()).map_err(|error| {
692 let message: String = format!(
693 "cli-git: could not inspect worktree-copy journals under {}.",
694 quote(root.to_string_lossy().as_ref())
695 );
696 return failure_with_cause(message.as_str(), Some(&error));
697 })?;
698 for listed in listing {
699 let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
700 let name: String = entry.file_name().to_string_lossy().into_owned();
701 if name.ends_with(JOURNAL_SUFFIX) {
702 names.push(name);
703 }
704 }
705 names.sort_by(|left, right| return js_order(left, right));
706 let mut pending: Vec<PendingJournal> = Vec::new();
707 for name in names {
708 let path: PathBuf = root.join(name.as_str());
709 assert_private_path(path.as_path(), Role::JournalFile)?;
710 let text: String = read_journal_text(path.as_path())?;
711 let journal: Journal = parse_journal(text.as_str(), path.as_path(), root.as_path())?;
712 pending.push(PendingJournal { path, journal });
713 }
714 return Ok(pending);
715}
716
717fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
727 let bytes: Vec<u8> = std::fs::read(path).map_err(|error| {
728 let message: String = format!(
729 "cli-git: could not read worktree-copy journal {}.",
730 quote(path.to_string_lossy().as_ref())
731 );
732 return failure_with_cause(message.as_str(), Some(&error));
733 })?;
734 return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
735}
736
737pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
747 use std::os::unix::fs::PermissionsExt;
748 let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
749 while let Some(directory) = pending.pop() {
750 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
752 Ok(metadata) => metadata,
753 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
754 Err(error) => return Err(raw_failure(&error)),
755 };
756 if !metadata.is_dir() {
757 continue;
758 }
759 std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
760 .map_err(|error| return raw_failure(&error))?;
761 for listed in
762 std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
763 {
764 let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
765 let child_type: std::fs::FileType = child
766 .file_type()
767 .map_err(|error| return raw_failure(&error))?;
768 if child_type.is_dir() {
769 pending.push(child.path());
770 }
771 }
772 }
773 return Ok(());
774}
775
776pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
786 if pending.journal.phase != Phase::Complete {
787 let completed: Journal = Journal {
788 phase: Phase::Complete,
789 ..pending.journal.clone()
790 };
791 write_journal(pending.path.as_path(), &completed)?;
792 }
793 let stage: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
794 prepare_stage_removal(stage.as_path())?;
795 remove_tree_if_present(stage.as_path())?;
796 remove_file_if_present(pending.path.as_path())?;
797 remove_unfinished_writes(pending.path.as_path())?;
798 if let Some(parent) = pending.path.parent() {
799 sync_directory(parent).map_err(|error| return raw_failure(&error))?;
800 }
801 return Ok(());
802}
803
804fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
814 match std::fs::remove_dir_all(path) {
816 Ok(()) => return Ok(()),
817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
818 Err(error) => return Err(raw_failure(&error)),
819 }
820}
821
822fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
832 match std::fs::remove_file(path) {
834 Ok(()) => return Ok(()),
835 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
836 Err(error) => return Err(raw_failure(&error)),
837 }
838}
839
840fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
850 let Some(parent) = path.parent() else {
851 return Ok(());
852 };
853 let prefix: String = format!(
854 "{}.",
855 path.file_name().unwrap_or_default().to_string_lossy()
856 );
857 for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
858 let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
859 let name: String = entry.file_name().to_string_lossy().into_owned();
860 if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
861 remove_file_if_present(entry.path().as_path())?;
862 }
863 }
864 return Ok(());
865}
866
867#[cfg(test)]
869#[path = "worktree_copy_journal_tests.rs"]
870mod tests;