Skip to main content

git_policy_cli/worktree/
worktree_add_binding.rs

1//! What:
2//!  The binding of one `git worktree add` to the objects real Git made for it (the destination directory and the
3//!  administrative directory that registers it), the record that carries the binding, and the check that the binding still
4//!  holds.
5//! Why:
6//!  The wrapper completes an add in place, through the bound objects, and never deletes or unregisters a worktree: a path
7//!  can be changed by another process between any check and any act, so no pathname or identity recorded earlier proves the
8//!  right to remove something later. The binding is what a fast rung and the fallback checkout verify before they write into
9//!  the worktree, and what the report of an interrupted add names. It is captured right after Git returned, and an add
10//!  whose pair cannot be proven is left exactly as Git made it.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const bound = await bindRegistration(commonDir, destination); // throws when the pair is not proven
15//! await claim.recordBinding(bound.binding);
16//! await verifyAssociation(bound); // throws when the objects are no longer where and what they were
17//! ```
18
19use 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/// What an add bound itself to: the two directories Git made and the pointer it wrote.
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct Binding {
28    /// The destination, a canonical path.
29    pub destination: PathBuf,
30    /// The identity of the destination directory when Git returned.
31    pub destination_id: ObjectId,
32    /// The administrative directory, a canonical path directly below the repository's `worktrees`.
33    pub admin: PathBuf,
34    /// The identity of the administrative directory when Git returned.
35    pub admin_id: ObjectId,
36    /// The bytes of the `.git` file Git wrote in the destination.
37    pub pointer: Vec<u8>,
38}
39
40/// A binding with the two directories held open.
41#[derive(Debug)]
42pub struct Bound {
43    /// The binding, for the record.
44    pub binding: Binding,
45    /// The destination directory as it was when it was bound.
46    pub destination: File,
47    /// The administrative directory as it was when it was bound.
48    pub admin: File,
49}
50
51/// Lower-case hexadecimal of bytes.
52fn hex(bytes: &[u8]) -> String {
53    return bytes.iter().map(|byte: &u8| { return format!("{byte:02x}"); }).collect();
54}
55
56/// The bytes of lower-case hexadecimal text.
57fn 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
64/// The text of a path for a record: hexadecimal, so that a newline in a name cannot forge a field.
65pub fn encode_path(path: &Path) -> String {
66    return hex(path.as_os_str().as_bytes());
67}
68
69/// The path of a record's text.
70pub 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    /// The record text of the binding.
77    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/// What a record says about an add.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub enum Recorded {
92    /// The add was planned and not bound: no object is known to be the add's.
93    Planned(PathBuf),
94    /// The add was bound to the objects Git made.
95    Registered(Binding),
96}
97
98/// What:
99///  Read the text of an add's record.
100/// Why:
101///   A record that does not parse in full is treated as planned only when its destination field parses; anything else is
102///  unreadable and authorizes nothing.
103///
104/// In TS you'd write (pseudocode):
105/// ```ts
106/// function decodeRecord(text: string): Recorded | undefined;
107/// ```
108pub 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
126/// The record text of a planned add.
127pub fn planned_record(destination: &Path) -> String {
128    return format!("destination={}\nphase=planned\n", encode_path(destination));
129}
130
131/// The identity of the object that the canonical path `path` names now, or nothing.
132///
133/// Why: a held handle keeps naming the directory it was opened on, whatever the path names later; this asks the path.
134pub 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
139/// What:
140///  Bind an add to the directories Git just made for it, after proving that they belong together and to this repository.
141/// Why:
142///   The pointer must name an administrative directory directly below this repository's `worktrees`, and that directory's
143///  `gitdir` must name the pointer back; only then are the identities of both directories and the pointer bytes captured,
144///  with both directories held open. A pointer edited to name another worktree's administrative directory fails the
145///  back-link.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// const bound = await bindRegistration(commonDir, destination); // throws a reason
150/// ```
151pub 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    // The target is opened as a canonical root first: a pointer through a link is refused here, with its own message.
158    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
178/// The message for a pointer whose target reaches the registration through a link or by a non-canonical path.
179fn 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
183/// What:
184///  The place a registration's `gitdir` file names, absolute whether Git wrote it absolute or relative to the registration.
185/// Why:
186///   Git writes the destination's `.git` path relative to the registration's directory under `--relative-paths` and
187///  `worktree.useRelativePaths`; the pair is the same either way, and a copy must be made for either.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// resolve(adminDirectory, recordedGitdirLine) === join(destination, '.git');
192/// ```
193fn 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
197/// What:
198///  `named` joined onto `base` and cleaned of `.` and `..` by the text alone, or an error when it climbs out of the root.
199/// Why:
200///   Both bases are canonical directories opened without a link, so the text decides, as Git's own resolution does; a link
201///  in the result is refused where the result is opened.
202///
203/// In TS you'd write (pseudocode):
204/// ```ts
205/// path.resolve(base, named);
206/// ```
207fn 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
223/// What:
224///  The registration directory a `gitdir:` pointer names, resolved lexically against the destination.
225/// Why:
226///   The destination is a canonical path, so `.` and `..` in a relative pointer resolve by their text; a link anywhere in the
227///  result is then caught by opening it as a canonical root, and no alias is ever followed to find the registration.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// const target = pointerTarget(destination, pointerBytes); // throws a reason
232/// ```
233fn 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
240/// The bytes of the file `name` of the held directory, read through the handle and not through a path.
241fn 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
248/// What:
249///  Check that the objects an add was bound to are still where and what they were: the destination and the registration
250///  still have the bound identities at their paths, the pointer still has the bound bytes, and the registration still
251///  names the pointer.
252/// Why:
253///   A write into the worktree, or a checkout by Git, must go to the exact worktree the add made; if another process moved
254///  or replaced either object, the step stops and the add is reported, with nothing changed.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// await verifyAssociation(bound); // throws the first difference
259/// ```
260pub 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    // What Git resolves the unchanged pointer to now: the target is opened without a link and must be the held registration.
274    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/// Binding controls stay out of the release executable.
288#[cfg(test)]
289#[path = "worktree_add_binding_tests.rs"]
290mod tests;