git_policy_cli/worktree/
worktree_add_binding.rs1use crate::fs_beneath::{ObjectId, identity_of, open_file_at, open_root};
20use std::ffi::OsStr;
21use std::fs::File;
22use std::os::unix::ffi::OsStrExt;
23use std::path::{Path, PathBuf};
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct Binding {
28 pub destination: PathBuf,
30 pub destination_id: ObjectId,
32 pub admin: PathBuf,
34 pub admin_id: ObjectId,
36 pub pointer: Vec<u8>,
38}
39
40#[derive(Debug)]
42pub struct Bound {
43 pub binding: Binding,
45 pub destination: File,
47 pub admin: File,
49}
50
51fn hex(bytes: &[u8]) -> String {
53 return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
54}
55
56fn unhex(text: &str) -> Option<Vec<u8>> {
58 if text.len() % 2 != 0 {
59 return None;
60 }
61 return (0..text.len()).step_by(2).map(|start: usize| { return u8::from_str_radix(text.get(start..start + 2)?, 16).ok(); }).collect();
62}
63
64pub fn encode_path(path: &Path) -> String {
66 return hex(path.as_os_str().as_bytes());
67}
68
69pub fn decode_path(text: &str) -> Option<PathBuf> {
71 use std::os::unix::ffi::OsStringExt;
72 return Some(PathBuf::from(std::ffi::OsString::from_vec(unhex(text)?)));
73}
74
75impl Binding {
76 pub fn encode(&self) -> String {
78 return format!(
79 "destination={}\nphase=registered\ndestination_id={}\nadmin={}\nadmin_id={}\npointer={}\n",
80 encode_path(self.destination.as_path()),
81 self.destination_id.encode(),
82 encode_path(self.admin.as_path()),
83 self.admin_id.encode(),
84 hex(self.pointer.as_slice()),
85 );
86 }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
91pub enum Recorded {
92 Planned(PathBuf),
94 Registered(Binding),
96}
97
98pub fn decode_record(text: &str) -> Option<Recorded> {
109 let field = |key: &str| -> Option<&str> { return text.lines().find_map(|line: &str| { return line.strip_prefix(key).and_then(|rest: &str| { return rest.strip_prefix('='); }); }); };
110 let destination: PathBuf = decode_path(field("destination")?)?;
111 if field("phase")? == "planned" {
112 return Some(Recorded::Planned(destination));
113 }
114 if field("phase")? != "registered" {
115 return None;
116 }
117 return Some(Recorded::Registered(Binding {
118 destination,
119 destination_id: ObjectId::decode(field("destination_id")?)?,
120 admin: decode_path(field("admin")?)?,
121 admin_id: ObjectId::decode(field("admin_id")?)?,
122 pointer: unhex(field("pointer")?)?,
123 }));
124}
125
126pub fn planned_record(destination: &Path) -> String {
128 return format!("destination={}\nphase=planned\n", encode_path(destination));
129}
130
131pub fn path_identity(path: &Path) -> Option<ObjectId> {
135 let parent: File = open_root(path.parent()?).ok()?;
136 return crate::fs_beneath::identity_at(&parent, path.file_name()?).ok();
137}
138
139pub fn bind_registration(common_dir: &Path, destination: &Path) -> Result<Bound, String> {
152 let shown: String = destination.display().to_string();
153 let held: File = open_root(destination).map_err(|error| { return format!("{shown} is not a real directory ({error})"); })?;
154 let pointer: Vec<u8> = read_through(&held, OsStr::new(".git")).map_err(|error| { return format!("{shown}/.git is unreadable ({error})"); })?;
155 let pointer_path: PathBuf = destination.join(".git");
156 let admin_path: PathBuf = pointer_target(destination, pointer.as_slice())?;
157 let admin: File = open_root(admin_path.as_path()).map_err(|error| { return not_canonical(shown.as_str(), admin_path.as_path(), error.to_string().as_str()); })?;
159 let worktrees: PathBuf = std::fs::canonicalize(common_dir.join("worktrees")).map_err(|error| { return format!("the worktrees of the repository are unreadable ({error})"); })?;
160 if admin_path.parent() != Some(worktrees.as_path()) {
161 return Err(format!("{shown} is not registered in this repository"));
162 }
163 let recorded: Vec<u8> = read_through(&admin, OsStr::new("gitdir")).map_err(|error| { return format!("the registration of {shown} is unreadable ({error})"); })?;
164 let recorded_line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
165 if recorded_destination(admin_path.as_path(), recorded_line)?.as_path() != pointer_path.as_path() {
166 return Err(format!("the registration {} does not name {shown}", admin_path.display()));
167 }
168 let binding: Binding = Binding {
169 destination: destination.to_path_buf(),
170 destination_id: identity_of(&held).map_err(|error| { return format!("{shown} cannot be examined ({error})"); })?,
171 admin: admin_path,
172 admin_id: identity_of(&admin).map_err(|error| { return format!("the registration cannot be examined ({error})"); })?,
173 pointer,
174 };
175 return Ok(Bound { binding, destination: held, admin });
176}
177
178fn not_canonical(shown: &str, target: &Path, why: &str) -> String {
180 return format!("the pointer in {shown}/.git names {}, which cannot be resolved without a symbolic link to the registration ({why}); the add is left as Git made it: make the worktree with a canonical path, or replace the link in the pointer with the real directory", target.display());
181}
182
183fn recorded_destination(admin: &Path, recorded: &[u8]) -> Result<PathBuf, String> {
194 return resolve_lexically(admin, recorded).map_err(|()| { return format!("the registration {} names a place outside the file system root", admin.display()); });
195}
196
197fn resolve_lexically(base: &Path, named: &[u8]) -> Result<PathBuf, ()> {
208 let mut resolved: PathBuf = PathBuf::new();
209 for part in base.join(OsStr::from_bytes(named)).components() {
210 match part {
211 std::path::Component::CurDir => {}
212 std::path::Component::ParentDir => {
213 if !resolved.pop() {
214 return Err(());
215 }
216 }
217 other => resolved.push(other.as_os_str()),
218 }
219 }
220 return Ok(resolved);
221}
222
223fn pointer_target(destination: &Path, pointer: &[u8]) -> Result<PathBuf, String> {
234 let shown: String = destination.display().to_string();
235 let line: &[u8] = pointer.strip_suffix(b"\n").unwrap_or(pointer);
236 let named: &[u8] = line.strip_prefix(b"gitdir: ").ok_or_else(|| { return format!("{shown}/.git is not a gitdir pointer"); })?;
237 return resolve_lexically(destination, named).map_err(|()| { return format!("the pointer in {shown}/.git climbs out of the file system root"); });
238}
239
240fn read_through(directory: &File, name: &OsStr) -> std::io::Result<Vec<u8>> {
242 let mut file: File = open_file_at(directory, name)?;
243 let mut bytes: Vec<u8> = Vec::new();
244 std::io::Read::read_to_end(&mut file, &mut bytes)?;
245 return Ok(bytes);
246}
247
248pub fn verify_association(bound: &Bound) -> Result<(), String> {
261 let binding: &Binding = &bound.binding;
262 let shown: String = binding.destination.display().to_string();
263 if path_identity(binding.destination.as_path()) != Some(binding.destination_id) {
264 return Err(format!("{shown} is no longer the directory Git made for this add"));
265 }
266 if path_identity(binding.admin.as_path()) != Some(binding.admin_id) {
267 return Err(format!("the registration {} is no longer the one Git made for this add", binding.admin.display()));
268 }
269 let pointer: Vec<u8> = read_through(&bound.destination, OsStr::new(".git")).map_err(|error| { return format!("{shown}/.git is unreadable ({error})"); })?;
270 if pointer != binding.pointer {
271 return Err(format!("{shown}/.git is no longer the pointer Git wrote"));
272 }
273 let target: PathBuf = pointer_target(binding.destination.as_path(), pointer.as_slice())?;
275 let resolved: File = open_root(target.as_path()).map_err(|error| { return not_canonical(shown.as_str(), target.as_path(), error.to_string().as_str()); })?;
276 if identity_of(&resolved).ok() != Some(binding.admin_id) || identity_of(&bound.admin).ok() != Some(binding.admin_id) {
277 return Err(format!("the pointer in {shown}/.git now resolves to another registration than the one Git made for this add"));
278 }
279 let recorded: Vec<u8> = read_through(&bound.admin, OsStr::new("gitdir")).map_err(|error| { return format!("the registration of {shown} is unreadable ({error})"); })?;
280 let line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
281 if recorded_destination(binding.admin.as_path(), line)?.as_path() != binding.destination.join(".git").as_path() {
282 return Err(format!("the registration {} no longer names {shown}", binding.admin.display()));
283 }
284 return Ok(());
285}
286
287#[cfg(test)]
289#[path = "worktree_add_binding_tests.rs"]
290mod tests;