Files
zed/crates/git/src/commit.rs
T
Dino 697e5be795 git: Fix commit message generation in untrusted projects and block external diff (#51323)
When on a untrusted project, if one was to try and use the commit
generation functionality, the command would fail because of the `-c
diff.external` configuration provided in `GitBinary::build_command`, as
git would interpret this as `""` and try to run that command.

This `-c diff.external` is a good safeguard to have on untrusted
repositories because it prevents random commands, configured in
`.git/config` from being run. For example, if one uses `git config
diff.external "touch bananas.txt"` and then run `git diff`, a new
`bananas.txt` file would be created.

However, it was still possible to bypass this safeguard using the
following strategy:

1. Specify a custom diff for a specific file format. For example, for
markdown files, with `printf '*.md diff=pwned\n' > .gitattributes`
2. Update the command run by the `pwned` diff, for example, `git config
diff.pwned.command "touch bananas.txt"`
3. Open Zed and attempt to generate a commit message in an untrusted
repository and check that a new `bananas.txt` file was created

This is only prevented by using the `--no-ext-diff` flag on the `diff`
command, so a new `GitBinary::build_diff_command` has been introduced
which simply wraps `GitBinary::build_command` and adds the
`--no-ext-diff` flag, if necessary.

As a side-effect, this also makes it so that generating a commit message
in an untrusted repository works again, which was accidentally broken on
https://github.com/zed-industries/zed/pull/50649 .

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects
- [X] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Fixed commit message generation in untrusted repositories
2026-03-13 15:08:12 +01:00

163 lines
5.4 KiB
Rust

use crate::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
repository::GitBinary, status::StatusCode,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::SharedString;
use std::sync::Arc;
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<url::Url>,
pub pull_request: Option<crate::hosting_provider::PullRequest>,
pub remote: Option<GitRemote>,
}
impl ParsedCommitMessage {
pub fn parse(
sha: String,
message: String,
remote_url: Option<&str>,
provider_registry: Option<Arc<GitHostingProviderRegistry>>,
) -> Self {
if let Some((hosting_provider, remote)) = provider_registry
.and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
{
let pull_request = hosting_provider.extract_pull_request(&remote, &message);
Self {
message: message.into(),
permalink: Some(
hosting_provider
.build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
),
pull_request,
remote: Some(GitRemote {
host: hosting_provider,
owner: remote.owner.into(),
repo: remote.repo.into(),
}),
}
} else {
Self {
message: message.into(),
..Default::default()
}
}
}
}
pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
if shas.is_empty() {
return Ok(HashMap::default());
}
let output = if cfg!(windows) {
// Windows has a maximum invocable command length, so we chunk the input.
// Actual max is 32767, but we leave some room for the rest of the command as we aren't in precise control of what std might do here
const MAX_CMD_LENGTH: usize = 30000;
// 40 bytes of hash, 2 quotes and a separating space
const SHA_LENGTH: usize = 40 + 2 + 1;
const MAX_ENTRIES_PER_INVOCATION: usize = MAX_CMD_LENGTH / SHA_LENGTH;
let mut result = vec![];
for shas in shas.chunks(MAX_ENTRIES_PER_INVOCATION) {
let partial = get_messages_impl(git, shas).await?;
result.extend(partial);
}
result
} else {
get_messages_impl(git, shas).await?
};
Ok(shas
.iter()
.cloned()
.zip(output)
.collect::<HashMap<Oid, String>>())
}
async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>> {
const MARKER: &str = "<MARKER>";
let output = git
.build_command(&["show"])
.arg("-s")
.arg(format!("--format=%B{}", MARKER))
.args(shas.iter().map(ToString::to_string))
.output()
.await
.context("starting git show process")?;
anyhow::ensure!(
output.status.success(),
"'git show' failed with error {:?}",
output.status
);
Ok(String::from_utf8_lossy(&output.stdout)
.trim()
.split_terminator(MARKER)
.map(|str| str.trim().replace("<", "&lt;").replace(">", "&gt;"))
.collect::<Vec<_>>())
}
/// Parse the output of `git diff --name-status -z`
pub fn parse_git_diff_name_status(content: &str) -> impl Iterator<Item = (&str, StatusCode)> {
let mut parts = content.split('\0');
std::iter::from_fn(move || {
loop {
let status_str = parts.next()?;
let path = parts.next()?;
let status = match status_str {
"M" => StatusCode::Modified,
"A" => StatusCode::Added,
"D" => StatusCode::Deleted,
_ => continue,
};
return Some((path, status));
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_git_diff_name_status() {
let input = concat!(
"M\x00Cargo.lock\x00",
"M\x00crates/project/Cargo.toml\x00",
"M\x00crates/project/src/buffer_store.rs\x00",
"D\x00crates/project/src/git.rs\x00",
"A\x00crates/project/src/git_store.rs\x00",
"A\x00crates/project/src/git_store/git_traversal.rs\x00",
"M\x00crates/project/src/project.rs\x00",
"M\x00crates/project/src/worktree_store.rs\x00",
"M\x00crates/project_panel/src/project_panel.rs\x00",
);
let output = parse_git_diff_name_status(input).collect::<Vec<_>>();
assert_eq!(
output,
&[
("Cargo.lock", StatusCode::Modified),
("crates/project/Cargo.toml", StatusCode::Modified),
("crates/project/src/buffer_store.rs", StatusCode::Modified),
("crates/project/src/git.rs", StatusCode::Deleted),
("crates/project/src/git_store.rs", StatusCode::Added),
(
"crates/project/src/git_store/git_traversal.rs",
StatusCode::Added,
),
("crates/project/src/project.rs", StatusCode::Modified),
("crates/project/src/worktree_store.rs", StatusCode::Modified),
(
"crates/project_panel/src/project_panel.rs",
StatusCode::Modified
),
]
);
}
}