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
59pub const 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 return write_atomic(path, encode_journal(journal).as_bytes(), "journal");
602}
603
604pub fn write_atomic(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
614 let unique: String = random_uuid().map_err(|error| {
615 return failure_with_cause(
616 format!(
617 "cli-git: could not persist worktree-copy {subject} {}.",
618 quote(path.to_string_lossy().as_ref())
619 )
620 .as_str(),
621 Some(&error.0),
622 );
623 })?;
624 let temporary: PathBuf = PathBuf::from(format!(
625 "{}.{unique}{TEMPORARY_SUFFIX}",
626 path.to_string_lossy()
627 ));
628 let persisted: std::io::Result<()> =
629 write_exclusive_synced(temporary.as_path(), bytes)
630 .and_then(|()| return std::fs::rename(temporary.as_path(), path))
631 .and_then(|()| match path.parent() {
632 Some(parent) => return sync_directory(parent),
633 None => return Ok(()),
634 });
635 if let Err(error) = persisted {
637 let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
638 return Err(failure_with_cause(
639 format!(
640 "cli-git: could not persist worktree-copy {subject} {}.",
641 quote(path.to_string_lossy().as_ref())
642 )
643 .as_str(),
644 Some(&error),
645 ));
646 }
647 return Ok(());
648}
649
650pub fn create_journal(
660 common_dir: &Path,
661 destination_root: &Path,
662 snapshot: &StagedSnapshot,
663) -> Result<PendingJournal, WorktreeCopyError> {
664 let root: PathBuf = ensure_journal_root(common_dir)?;
665 let unique: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
666 let path: PathBuf = root.join(format!("{unique}{JOURNAL_SUFFIX}"));
667 let journal: Journal = Journal {
668 created_entries: Vec::new(),
669 intended_entries: Vec::new(),
670 destination_root: destination_root.to_string_lossy().into_owned(),
671 phase: Phase::Staged,
672 selected_roots: snapshot.selected_roots.clone(),
673 source_root: snapshot.source_root.to_string_lossy().into_owned(),
674 stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
675 stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
676 };
677 write_journal(path.as_path(), &journal)?;
678 return Ok(PendingJournal { path, journal });
679}
680
681pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
691 let root: PathBuf = journal_root(common_dir);
692 match std::fs::symlink_metadata(root.as_path()) {
694 Ok(_) => {}
695 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
696 Err(error) => return Err(raw_failure(&error)),
697 }
698 assert_private_path(
699 root.parent().unwrap_or(root.as_path()),
700 Role::JournalDirectory,
701 )?;
702 assert_private_path(root.as_path(), Role::JournalDirectory)?;
703 let mut names: Vec<String> = Vec::new();
704 let listing = std::fs::read_dir(root.as_path()).map_err(|error| {
705 let message: String = format!(
706 "cli-git: could not inspect worktree-copy journals under {}.",
707 quote(root.to_string_lossy().as_ref())
708 );
709 return failure_with_cause(message.as_str(), Some(&error));
710 })?;
711 for listed in listing {
712 let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
713 let name: String = entry.file_name().to_string_lossy().into_owned();
714 if name.ends_with(JOURNAL_SUFFIX) {
715 names.push(name);
716 }
717 }
718 names.sort_by(|left, right| return js_order(left, right));
719 let mut pending: Vec<PendingJournal> = Vec::new();
720 for name in names {
721 let path: PathBuf = root.join(name.as_str());
722 assert_private_path(path.as_path(), Role::JournalFile)?;
723 let text: String = read_journal_text(path.as_path())?;
724 let journal: Journal = parse_journal(text.as_str(), path.as_path(), root.as_path())?;
725 pending.push(PendingJournal { path, journal });
726 }
727 return Ok(pending);
728}
729
730fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
740 let bytes: Vec<u8> = std::fs::read(path).map_err(|error| {
741 let message: String = format!(
742 "cli-git: could not read worktree-copy journal {}.",
743 quote(path.to_string_lossy().as_ref())
744 );
745 return failure_with_cause(message.as_str(), Some(&error));
746 })?;
747 return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
748}
749
750pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
760 use std::os::unix::fs::PermissionsExt;
761 let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
762 while let Some(directory) = pending.pop() {
763 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
765 Ok(metadata) => metadata,
766 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
767 Err(error) => return Err(raw_failure(&error)),
768 };
769 if !metadata.is_dir() {
770 continue;
771 }
772 std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
773 .map_err(|error| return raw_failure(&error))?;
774 for listed in
775 std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
776 {
777 let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
778 let child_type: std::fs::FileType = child
779 .file_type()
780 .map_err(|error| return raw_failure(&error))?;
781 if child_type.is_dir() {
782 pending.push(child.path());
783 }
784 }
785 }
786 return Ok(());
787}
788
789pub fn remove_stage(stage_container: &Path) -> Result<(), WorktreeCopyError> {
799 prepare_stage_removal(stage_container)?;
800 return remove_tree_if_present(stage_container);
801}
802
803pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
813 if pending.journal.phase != Phase::Complete {
814 let completed: Journal = Journal {
815 phase: Phase::Complete,
816 ..pending.journal.clone()
817 };
818 write_journal(pending.path.as_path(), &completed)?;
819 }
820 remove_stage(Path::new(pending.journal.stage_container.as_str()))?;
821 remove_file_if_present(pending.path.as_path())?;
822 remove_unfinished_writes(pending.path.as_path())?;
823 if let Some(parent) = pending.path.parent() {
824 sync_directory(parent).map_err(|error| return raw_failure(&error))?;
825 }
826 return Ok(());
827}
828
829fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
839 match std::fs::remove_dir_all(path) {
841 Ok(()) => return Ok(()),
842 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
843 Err(error) => return Err(raw_failure(&error)),
844 }
845}
846
847pub fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
857 match std::fs::remove_file(path) {
859 Ok(()) => return Ok(()),
860 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
861 Err(error) => return Err(raw_failure(&error)),
862 }
863}
864
865fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
875 let Some(parent) = path.parent() else {
876 return Ok(());
877 };
878 let prefix: String = format!(
879 "{}.",
880 path.file_name().unwrap_or_default().to_string_lossy()
881 );
882 for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
883 let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
884 let name: String = entry.file_name().to_string_lossy().into_owned();
885 if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
886 remove_file_if_present(entry.path().as_path())?;
887 }
888 }
889 return Ok(());
890}
891
892#[cfg(test)]
894#[path = "worktree_copy_journal_tests.rs"]
895mod tests;