diff options
| author | caner-akca <94343893+caner-akca@users.noreply.github.com> | 2026-08-30 01:40:49 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-30 01:40:49 +0300 |
| commit | 03ce02e1a7c04426a2881962da661c46e389e8bf (patch) | |
| tree | 3d3186503bbba3c822849d36158b253ccd9531a3 /src | |
| parent | f807b697353cfa00aa912c7cde4830e863001cf5 (diff) | |
fix: reject oversized git ref files in workspace status reads (#3373)
* fix: reject oversized git ref files in workspace status reads
refs #3343
* fix: treat oversized loose refs as unavailable instead of absent
refs #3343
* fix: classify git ref read errors instead of probing path existence
A loose ref behind a metadata or I/O error (for example an unsearchable
refs/heads directory) previously read as absent, because Path::exists()
also returns false on metadata errors, letting a stale same-name
packed-refs OID enter the workspace status fingerprint. The reader now
classifies the open error itself: NotFound and NotADirectory mean the
loose ref is absent (packed-refs fallback stays legitimate), every other
error marks the ref unavailable with no fallback. This also removes the
racy second existence probe.
refs #3343
* fix: reject empty loose git refs
refs #3343
* fix: reject dangling loose git refs
refs #3343
* fix: reject dangling git refs with invalid target paths
refs #3343
Diffstat (limited to 'src')
| -rw-r--r-- | src/workspace/git/discovery.rs | 276 | ||||
| -rw-r--r-- | src/workspace/git/status.rs | 5 |
2 files changed, 275 insertions, 6 deletions
diff --git a/src/workspace/git/discovery.rs b/src/workspace/git/discovery.rs index f4506073..de02fa73 100644 --- a/src/workspace/git/discovery.rs +++ b/src/workspace/git/discovery.rs @@ -1,5 +1,8 @@ +use std::io::Read; use std::path::{Path, PathBuf}; +const MAX_GIT_REF_FILE_BYTES: usize = 64 * 1024; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitSpaceMetadata { pub key: String, @@ -121,6 +124,65 @@ fn git_common_dir_for_git_dir(git_dir: &Path) -> PathBuf { } } +/// Outcome of reading one Git ref file, classified without collapsing metadata +/// errors so callers can distinguish "this ref does not exist" from "this ref +/// exists (or cannot be ruled out) but its content must not be trusted". +/// `Path::exists()` cannot distinguish them because it returns `false` on +/// metadata errors. When opening reports `NotFound` or `NotADirectory`, +/// `symlink_metadata` distinguishes a genuinely absent path from a dangling +/// symlink without following the final link. +pub(super) enum RefFileRead { + Content(String), + Absent, + Unavailable, +} + +pub(super) fn read_git_ref_file_state(path: &Path) -> RefFileRead { + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + return match std::fs::symlink_metadata(path) { + // The directory entry exists but its symlink target is missing + // or traverses a non-directory. Git treats the loose ref as + // broken and does not fall back to an older packed ref. + Ok(_) => RefFileRead::Unavailable, + Err(metadata_error) + if metadata_error.kind() == std::io::ErrorKind::NotFound + || metadata_error.kind() == std::io::ErrorKind::NotADirectory => + { + RefFileRead::Absent + } + Err(_) => RefFileRead::Unavailable, + }; + } + // Permission or I/O errors: the ref may exist, so its identity is + // unavailable rather than absent. + Err(_) => return RefFileRead::Unavailable, + }; + let mut contents = String::new(); + if file + .take((MAX_GIT_REF_FILE_BYTES + 1) as u64) + .read_to_string(&mut contents) + .is_err() + || contents.len() > MAX_GIT_REF_FILE_BYTES + { + return RefFileRead::Unavailable; + } + RefFileRead::Content(contents) +} + +pub(super) fn read_git_ref_file(path: &Path) -> Option<String> { + match read_git_ref_file_state(path) { + RefFileRead::Content(contents) => Some(contents), + RefFileRead::Absent | RefFileRead::Unavailable => None, + } +} + pub fn git_branch(cwd: &Path) -> Option<String> { let repo_root = git_repo_root(cwd)?; let git_dir = git_dir_for_repo_root(&repo_root)?; @@ -129,7 +191,7 @@ pub fn git_branch(cwd: &Path) -> Option<String> { return git_symbolic_head_short(&repo_root); } - let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = read_git_ref_file(&git_dir.join("HEAD"))?; parse_git_head_branch(&head) } @@ -268,11 +330,22 @@ pub(super) fn git_repo_root(start: &Path) -> Option<PathBuf> { pub(super) fn read_ref_oid(common_dir: &Path, full_ref: &str) -> Option<String> { let loose_ref = common_dir.join(full_ref); - if let Ok(contents) = std::fs::read_to_string(loose_ref) { - let oid = contents.trim(); - if !oid.is_empty() { + match read_git_ref_file_state(&loose_ref) { + RefFileRead::Content(contents) => { + let oid = contents.trim(); + if oid.is_empty() { + // An empty loose ref is present but broken. Git does not fall + // back to an older same-name packed ref in this case. + return None; + } return Some(oid.to_string()); } + // A loose ref that exists — or whose existence cannot be ruled out + // because of a metadata or I/O error — must not fall back to + // packed-refs: that could resurrect a stale same-name OID into the + // status fingerprint. Report the ref as unavailable instead. + RefFileRead::Unavailable => return None, + RefFileRead::Absent => {} } let packed_refs = std::fs::read_to_string(common_dir.join("packed-refs")).ok()?; @@ -326,6 +399,201 @@ mod tests { } #[test] + fn oversized_loose_ref_is_unavailable_not_absent() { + let root = temp_test_dir("oversized-loose-ref"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + std::fs::write( + root.join("packed-refs"), + "# pack-refs with: peeled fully-peeled sorted \naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + let loose = refs_dir.join("main"); + std::fs::write(&loose, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(loose) + .unwrap() + .set_len(8 * 1024 * 1024) + .unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main"); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid, None, + "an oversized loose ref must make the ref unavailable, not fall back to the stale packed OID" + ); + } + + #[test] + fn empty_or_whitespace_loose_ref_is_unavailable_not_absent() { + let root = temp_test_dir("empty-loose-ref"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + let loose = refs_dir.join("main"); + + for contents in ["", " \n\t"] { + std::fs::write(&loose, contents).unwrap(); + assert_eq!( + read_ref_oid(&root, "refs/heads/main"), + None, + "an empty or whitespace-only loose ref must not fall back to the stale packed OID" + ); + } + + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_loose_ref_is_unavailable_not_absent() { + use std::os::unix::fs::symlink; + + let root = temp_test_dir("dangling-symlink-loose-ref"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + symlink("missing-target", refs_dir.join("main")).unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main"); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid, None, + "a dangling loose ref must not fall back to the stale packed OID" + ); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_through_file_is_unavailable_not_absent() { + use std::os::unix::fs::symlink; + + let root = temp_test_dir("dangling-symlink-through-file"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + std::fs::write(refs_dir.join("target-parent"), "not a directory").unwrap(); + symlink("target-parent/nested", refs_dir.join("main")).unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main"); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid, None, + "a dangling loose ref whose target traverses a file must not fall back to the stale packed OID" + ); + } + + #[cfg(unix)] + #[test] + fn unreadable_loose_ref_dir_is_unavailable_not_absent() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_test_dir("unreadable-loose-ref"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + std::fs::write( + refs_dir.join("main"), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n", + ) + .unwrap(); + std::fs::set_permissions(&refs_dir, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main"); + std::fs::set_permissions(&refs_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid, None, + "a loose ref behind a metadata error must be unavailable, not fall back to the stale packed OID" + ); + } + + #[test] + fn ref_path_through_a_file_still_reads_packed_refs() { + let root = temp_test_dir("ref-path-through-file"); + let refs_dir = root.join("refs/heads"); + std::fs::create_dir_all(&refs_dir).unwrap(); + // refs/heads/main is a file, so refs/heads/main/nested cannot exist as + // a loose ref; the packed entry is the legitimate source. + std::fs::write( + refs_dir.join("main"), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n", + ) + .unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main/nested\n", + ) + .unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main/nested"); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + } + + #[test] + fn absent_loose_ref_still_reads_packed_refs() { + let root = temp_test_dir("packed-only-ref"); + std::fs::create_dir_all(root.join("refs/heads")).unwrap(); + std::fs::write( + root.join("packed-refs"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n", + ) + .unwrap(); + + let oid = read_ref_oid(&root, "refs/heads/main"); + std::fs::remove_dir_all(root).unwrap(); + assert_eq!( + oid.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + } + + #[test] + fn oversized_git_head_is_rejected() { + let root = temp_test_dir("oversized-head"); + let git_dir = root.join(".git"); + std::fs::create_dir_all(&git_dir).unwrap(); + let head = git_dir.join("HEAD"); + std::fs::write(&head, "ref: refs/heads/main\n").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(head) + .unwrap() + .set_len(60 * 1024 * 1024) + .unwrap(); + + let branch = git_branch(&root); + let branch_len = branch.as_ref().map(String::len); + std::fs::remove_dir_all(root).unwrap(); + + assert!( + branch.is_none(), + "oversized Git HEAD produced branch with {branch_len:?} bytes" + ); + } + + #[test] fn git_branch_reads_head_from_worktree_gitdir_file() { let root = temp_test_dir("worktree"); let worktree_git_dir = root.join(".bare/worktrees/feature"); diff --git a/src/workspace/git/status.rs b/src/workspace/git/status.rs index 4fb2b887..9f40170c 100644 --- a/src/workspace/git/status.rs +++ b/src/workspace/git/status.rs @@ -8,7 +8,8 @@ use super::{ discovery::{ automatic_workspace_label, canonicalize_best_effort_path, fallback_label_from_cwd, git_ref_storage_is_reftable, git_rev_parse_verify, git_space_metadata_from_info, - git_symbolic_head_full, git_worktree_info, read_ref_oid, GitWorktreeInfo, + git_symbolic_head_full, git_worktree_info, read_git_ref_file, read_ref_oid, + GitWorktreeInfo, }, }; @@ -273,7 +274,7 @@ fn read_head_identity_from_git(info: &GitWorktreeInfo) -> Option<GitHeadIdentity } fn read_head_identity_from_files(info: &GitWorktreeInfo) -> Option<GitHeadIdentity> { - let head = std::fs::read_to_string(info.git_dir.join("HEAD")).ok()?; + let head = read_git_ref_file(&info.git_dir.join("HEAD"))?; let head = head.trim(); if let Some(full_ref) = head.strip_prefix("ref: ") { let short_name = full_ref.strip_prefix("refs/heads/")?.to_string(); |
