Skip to content

Commit 26c32ec

Browse files
jamesadevineCopilot
andcommitted
refactor(safeoutputs): extract push_file_change_skipping_symlinks helper
Collapse 7 duplicated symlink_metadata match blocks introduced by the symlink-exfiltration fix into a single helper. Same security semantics: regular file → push change; symlink → warn and skip; other → ignore. Centralizes the use of symlink_metadata so the no-follow invariant lives in one place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 08e1fc2 commit 26c32ec

1 file changed

Lines changed: 54 additions & 97 deletions

File tree

src/safeoutputs/create_pull_request.rs

Lines changed: 54 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,33 +1656,13 @@ async fn collect_changes_from_worktree(
16561656
}
16571657
// New/untracked files
16581658
"??" | "A " | " A" | "AM" => {
1659-
match tokio::fs::symlink_metadata(&full_path).await {
1660-
Ok(meta) if meta.file_type().is_file() => {
1661-
changes.push(read_file_change("add", file_path, &full_path).await?);
1662-
}
1663-
Ok(meta) if meta.file_type().is_symlink() => {
1664-
warn!(
1665-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1666-
file_path
1667-
);
1668-
}
1669-
_ => {}
1670-
}
1659+
push_file_change_skipping_symlinks(&mut changes, "add", file_path, &full_path)
1660+
.await?;
16711661
}
16721662
// Modified files
16731663
" M" | "M " | "MM" => {
1674-
match tokio::fs::symlink_metadata(&full_path).await {
1675-
Ok(meta) if meta.file_type().is_file() => {
1676-
changes.push(read_file_change("edit", file_path, &full_path).await?);
1677-
}
1678-
Ok(meta) if meta.file_type().is_symlink() => {
1679-
warn!(
1680-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1681-
file_path
1682-
);
1683-
}
1684-
_ => {}
1685-
}
1664+
push_file_change_skipping_symlinks(&mut changes, "edit", file_path, &full_path)
1665+
.await?;
16861666
}
16871667
// Renamed files - format is "R old_path -> new_path"
16881668
// For "RM" (renamed + modified), we emit both a rename and an edit change.
@@ -1704,36 +1684,22 @@ async fn collect_changes_from_worktree(
17041684

17051685
// If status is "RM" (renamed + modified), also emit content
17061686
if status_code == "RM" {
1707-
let new_full_path = worktree_path.join(new_path.trim());
1708-
match tokio::fs::symlink_metadata(&new_full_path).await {
1709-
Ok(meta) if meta.file_type().is_file() => {
1710-
changes.push(read_file_change("edit", new_path.trim(), &new_full_path).await?);
1711-
}
1712-
Ok(meta) if meta.file_type().is_symlink() => {
1713-
warn!(
1714-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1715-
new_path.trim()
1716-
);
1717-
}
1718-
_ => {}
1719-
}
1687+
let new_path_trimmed = new_path.trim();
1688+
let new_full_path = worktree_path.join(new_path_trimmed);
1689+
push_file_change_skipping_symlinks(
1690+
&mut changes,
1691+
"edit",
1692+
new_path_trimmed,
1693+
&new_full_path,
1694+
)
1695+
.await?;
17201696
}
17211697
}
17221698
}
17231699
// Other statuses - try to handle as edit if file exists
17241700
_ => {
1725-
match tokio::fs::symlink_metadata(&full_path).await {
1726-
Ok(meta) if meta.file_type().is_file() => {
1727-
changes.push(read_file_change("edit", file_path, &full_path).await?);
1728-
}
1729-
Ok(meta) if meta.file_type().is_symlink() => {
1730-
warn!(
1731-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1732-
file_path
1733-
);
1734-
}
1735-
_ => {}
1736-
}
1701+
push_file_change_skipping_symlinks(&mut changes, "edit", file_path, &full_path)
1702+
.await?;
17371703
}
17381704
}
17391705
}
@@ -1780,18 +1746,7 @@ async fn collect_changes_from_diff_tree(
17801746
}));
17811747
} else if status_code == "A" {
17821748
// Added file
1783-
match tokio::fs::symlink_metadata(&full_path).await {
1784-
Ok(meta) if meta.file_type().is_file() => {
1785-
changes.push(read_file_change("add", file_path, &full_path).await?);
1786-
}
1787-
Ok(meta) if meta.file_type().is_symlink() => {
1788-
warn!(
1789-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1790-
file_path
1791-
);
1792-
}
1793-
_ => {}
1794-
}
1749+
push_file_change_skipping_symlinks(&mut changes, "add", file_path, &full_path).await?;
17951750
} else if status_code.starts_with('R') && parts.len() >= 3 {
17961751
// Renamed file: R100\told_path\tnew_path
17971752
let old_path = file_path;
@@ -1811,57 +1766,59 @@ async fn collect_changes_from_diff_tree(
18111766
// If the file was also modified (similarity < 100), emit an edit with content
18121767
let new_full_path = worktree_path.join(new_path);
18131768
if status_code != "R100" {
1814-
match tokio::fs::symlink_metadata(&new_full_path).await {
1815-
Ok(meta) if meta.file_type().is_file() => {
1816-
changes.push(read_file_change("edit", new_path, &new_full_path).await?);
1817-
}
1818-
Ok(meta) if meta.file_type().is_symlink() => {
1819-
warn!(
1820-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1821-
new_path
1822-
);
1823-
}
1824-
_ => {}
1825-
}
1769+
push_file_change_skipping_symlinks(
1770+
&mut changes,
1771+
"edit",
1772+
new_path,
1773+
&new_full_path,
1774+
)
1775+
.await?;
18261776
}
18271777
} else if status_code.starts_with('C') && parts.len() >= 3 {
18281778
// Copied file: C100\tsrc_path\tdest_path
18291779
let dest_path = parts[2];
18301780
validate_single_path(dest_path)?;
18311781

18321782
let dest_full_path = worktree_path.join(dest_path);
1833-
match tokio::fs::symlink_metadata(&dest_full_path).await {
1834-
Ok(meta) if meta.file_type().is_file() => {
1835-
changes.push(read_file_change("add", dest_path, &dest_full_path).await?);
1836-
}
1837-
Ok(meta) if meta.file_type().is_symlink() => {
1838-
warn!(
1839-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1840-
dest_path
1841-
);
1842-
}
1843-
_ => {}
1844-
}
1783+
push_file_change_skipping_symlinks(&mut changes, "add", dest_path, &dest_full_path)
1784+
.await?;
18451785
} else {
18461786
// Modified or other — read current content
1847-
match tokio::fs::symlink_metadata(&full_path).await {
1848-
Ok(meta) if meta.file_type().is_file() => {
1849-
changes.push(read_file_change("edit", file_path, &full_path).await?);
1850-
}
1851-
Ok(meta) if meta.file_type().is_symlink() => {
1852-
warn!(
1853-
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1854-
file_path
1855-
);
1856-
}
1857-
_ => {}
1858-
}
1787+
push_file_change_skipping_symlinks(&mut changes, "edit", file_path, &full_path)
1788+
.await?;
18591789
}
18601790
}
18611791

18621792
Ok(changes)
18631793
}
18641794

1795+
/// Push a file change into `changes`, skipping symlinks with a warning.
1796+
///
1797+
/// Centralizes the "regular file → read & push; symlink → warn & skip; other → ignore"
1798+
/// logic used in multiple places when collecting changes from a worktree or diff tree.
1799+
/// Uses `symlink_metadata` so symlinks are detected without being followed — this is
1800+
/// the primary defense against symlink-following exfiltration attacks in Stage 3.
1801+
async fn push_file_change_skipping_symlinks(
1802+
changes: &mut Vec<serde_json::Value>,
1803+
change_type: &str,
1804+
file_path: &str,
1805+
full_path: &std::path::Path,
1806+
) -> anyhow::Result<()> {
1807+
match tokio::fs::symlink_metadata(full_path).await {
1808+
Ok(meta) if meta.file_type().is_file() => {
1809+
changes.push(read_file_change(change_type, file_path, full_path).await?);
1810+
}
1811+
Ok(meta) if meta.file_type().is_symlink() => {
1812+
warn!(
1813+
"Skipping symlink in worktree: {} (symlink-following attack prevention)",
1814+
file_path
1815+
);
1816+
}
1817+
_ => {}
1818+
}
1819+
Ok(())
1820+
}
1821+
18651822
/// Read a file and produce an ADO push change entry.
18661823
/// Handles both text (rawtext) and binary (base64encoded) content.
18671824
async fn read_file_change(

0 commit comments

Comments
 (0)