From 0e90e28ee77792d3d1a7737d1fdd6e036c3cd2e6 Mon Sep 17 00:00:00 2001 From: liccode Date: Mon, 28 Sep 2026 04:11:48 +0800 Subject: [PATCH 1/2] feature: confirm before trusting a repository refused by git --- src/App.axaml.cs | 4 +- src/Commands/AddSafeDirectory.cs | 11 ++ src/Commands/Command.cs | 7 +- src/Commands/QueryFileContent.cs | 4 +- src/Commands/SaveChangesAsPatch.cs | 2 +- src/Commands/SaveRevisionFile.cs | 2 +- src/Commands/UpdateIndexInfo.cs | 2 +- src/Models/GitVersions.cs | 6 + src/Models/SafeDirectories.cs | 186 ++++++++++++++++++++++++++ src/Resources/Locales/en_US.axaml | 9 ++ src/Resources/Locales/zh_CN.axaml | 9 ++ src/Resources/Locales/zh_TW.axaml | 9 ++ src/Resources/Themes.axaml | 3 + src/ViewModels/Launcher.cs | 28 +++- src/ViewModels/OpenLocalRepository.cs | 23 +++- src/ViewModels/ScanRepositories.cs | 9 ++ src/ViewModels/TrustRepository.cs | 131 ++++++++++++++++++ src/ViewModels/Welcome.cs | 16 ++- src/Views/TrustRepository.axaml | 70 ++++++++++ src/Views/TrustRepository.axaml.cs | 12 ++ src/Views/Welcome.axaml.cs | 2 +- 21 files changed, 526 insertions(+), 19 deletions(-) create mode 100644 src/Commands/AddSafeDirectory.cs create mode 100644 src/Models/SafeDirectories.cs create mode 100644 src/ViewModels/TrustRepository.cs create mode 100644 src/Views/TrustRepository.axaml create mode 100644 src/Views/TrustRepository.axaml.cs diff --git a/src/App.axaml.cs b/src/App.axaml.cs index eef4684cee..66882b4869 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -350,7 +350,7 @@ private bool TryLaunchAsFileHistoryViewer(IClassicDesktopStyleApplicationLifetim var test = new Commands.QueryRepositoryRootPath(dir).GetResult(); if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) { - Console.Out.WriteLine($"'{args[1]}' is not in a valid git repository"); + Console.Out.WriteLine(string.IsNullOrWhiteSpace(test.StdErr) ? $"'{args[1]}' is not in a valid git repository" : test.StdErr.Trim()); desktop.Shutdown(-1); return true; } @@ -395,7 +395,7 @@ private bool TryLaunchAsBlameViewer(IClassicDesktopStyleApplicationLifetime desk var test = new Commands.QueryRepositoryRootPath(dir).GetResult(); if (!test.IsSuccess || string.IsNullOrEmpty(test.StdOut)) { - Console.Out.WriteLine($"'{args[1]}' is not in a valid git repository"); + Console.Out.WriteLine(string.IsNullOrWhiteSpace(test.StdErr) ? $"'{args[1]}' is not in a valid git repository" : test.StdErr.Trim()); desktop.Shutdown(-1); return true; } diff --git a/src/Commands/AddSafeDirectory.cs b/src/Commands/AddSafeDirectory.cs new file mode 100644 index 0000000000..52e190db69 --- /dev/null +++ b/src/Commands/AddSafeDirectory.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Commands +{ + public class AddSafeDirectory : Command + { + public AddSafeDirectory(string ctx, string value) + { + Context = ctx; + Args = $"config --global --add safe.directory {value.Quoted()}"; + } + } +} diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index ba68bb90da..44afa07e57 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -183,6 +183,9 @@ protected ProcessStartInfo CreateGitStartInfo(bool redirect) break; } + // Session-only `safe.directory` exceptions. See `Models.SafeDirectories` for details. + builder.Append(Models.SafeDirectories.GetSessionSafeDirectoryArgs(WorkingDirectory)); + builder.Append(Args); var start = new ProcessStartInfo(); @@ -210,8 +213,8 @@ protected ProcessStartInfo CreateGitStartInfo(bool redirect) if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey)) start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}' -o AddKeysToAgent=yes"); - // Force using en_US.UTF-8 locale - if (OperatingSystem.IsLinux()) + // Force the C locale on Unix, so that git's output (including fatal errors) can always be parsed. + if (!OperatingSystem.IsWindows()) { start.Environment.Add("LANG", "C"); start.Environment.Add("LC_ALL", "C"); diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index d111b5f210..8d7732cc94 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -12,7 +12,7 @@ public static async Task RunAsync(string repo, string revision, string f var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = $"show {revision}:{file.Quoted()}"; + starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + $"show {revision}:{file.Quoted()}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; @@ -39,7 +39,7 @@ public static async Task FromLFSAsync(string repo, string oid, long size var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = "lfs smudge"; + starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + "lfs smudge"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs index 4ec83a6a63..2f7842535c 100644 --- a/src/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -54,7 +54,7 @@ private static async Task ProcessSingleChangeAsync(string repo, Models.Dif var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = $"diff --no-color --no-ext-diff --ignore-cr-at-eol --unified=4 {opt}"; + starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + $"diff --no-color --no-ext-diff --ignore-cr-at-eol --unified=4 {opt}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs index 6c810289d1..9ad1206cca 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -30,7 +30,7 @@ private static async Task ExecCmdAsync(string repo, string args, string outputFi var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = args; + starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + args; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/UpdateIndexInfo.cs b/src/Commands/UpdateIndexInfo.cs index d8c47ca91c..dbf133d382 100644 --- a/src/Commands/UpdateIndexInfo.cs +++ b/src/Commands/UpdateIndexInfo.cs @@ -53,7 +53,7 @@ public async Task ExecAsync() var starter = new ProcessStartInfo(); starter.WorkingDirectory = _repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = "-c core.editor=true update-index --index-info"; + starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(_repo) + "-c core.editor=true update-index --index-info"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Models/GitVersions.cs b/src/Models/GitVersions.cs index 71fb4657ec..ea625338b9 100644 --- a/src/Models/GitVersions.cs +++ b/src/Models/GitVersions.cs @@ -17,6 +17,12 @@ public static class GitVersions /// public static readonly System.Version STASH_PUSH_ONLY_STAGED = new(2, 35, 0); + /// + /// The minimal version of Git that respects the `safe.directory` exception passed with the `-c` + /// command line option. Older versions can only read the exception from the system/global config. + /// + public static readonly System.Version SAFE_DIRECTORY_COMMAND_LINE = new(2, 38, 0); + /// /// The minimal version of Git that supports the `git merge-tree --write-tree` command, which is used for testing merge results without actually performing a merge. /// diff --git a/src/Models/SafeDirectories.cs b/src/Models/SafeDirectories.cs new file mode 100644 index 0000000000..87e3ff169f --- /dev/null +++ b/src/Models/SafeDirectories.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + /// + /// Helpers for the `safe.directory` protection introduced by git 2.35.2 (CVE-2022-24765). + /// Git refuses to use a repository whose top-level directory is owned by another user (which is + /// always the case for network shares / UNC paths) unless the path is listed in the `safe.directory` config. + /// + public static partial class SafeDirectories + { + public static bool IsUntrustedRepository(string output) + { + if (string.IsNullOrWhiteSpace(output)) + return false; + + return output.Contains("detected dubious ownership", StringComparison.OrdinalIgnoreCase) || + output.Contains("unsafe repository", StringComparison.OrdinalIgnoreCase); + } + + /// + /// `-c safe.directory=` is only respected by git 2.38 and later. Older versions (2.35.2 - 2.37.x) + /// can only read the exception from the system/global config. + /// + public static bool SupportsSessionTrust() + { + return Native.OS.GitVersion >= GitVersions.SAFE_DIRECTORY_COMMAND_LINE; + } + + /// + /// Tries to get the value that should be written into the `safe.directory` config. + /// Prefers the value suggested by git itself (which knows the correct form for the current platform), + /// and falls back to building a value from the given path. + /// + public static bool TryGetSafeDirectoryValue(string path, string output, out string value) + { + value = ParseSuggestedValue(output); + if (!string.IsNullOrEmpty(value)) + return true; + + var normalized = Normalize(path); + if (string.IsNullOrEmpty(normalized)) + return false; + + // Git for Windows uses the `%(prefix)/` prefix for UNC paths. + value = OperatingSystem.IsWindows() && normalized.StartsWith("//", StringComparison.Ordinal) ? $"%(prefix)/{normalized}" : normalized; + return true; + } + + /// + /// Trusts a directory only for the current session. The exception will be passed to git with + /// `-c safe.directory=` for every command executed under that directory, but it will + /// NOT be persisted into the user's git config. + /// + public static void AddSessionTrust(string workingDirectory, string safeDirectory) + { + var normalized = Normalize(workingDirectory); + if (string.IsNullOrEmpty(normalized) || string.IsNullOrEmpty(safeDirectory)) + return; + + lock (s_sessionTrusted) + { + foreach (var one in s_sessionTrusted) + { + if (one.WorkingDirectory.Equals(normalized, s_comparison) && one.Value.Equals(safeDirectory, StringComparison.Ordinal)) + return; + } + + s_sessionTrusted.Add(new TrustEntry(normalized, safeDirectory)); + } + } + + public static List GetSessionSafeDirectories(string workingDirectory) + { + var outs = new List(); + var normalized = Normalize(workingDirectory); + if (string.IsNullOrEmpty(normalized)) + return outs; + + var seen = new HashSet(StringComparer.Ordinal); + lock (s_sessionTrusted) + { + foreach (var one in s_sessionTrusted) + { + if (seen.Contains(one.Value)) + continue; + + if (normalized.Equals(one.WorkingDirectory, s_comparison) || + normalized.StartsWith(one.WorkingDirectory + "/", s_comparison)) + { + seen.Add(one.Value); + outs.Add(one.Value); + } + } + } + + return outs; + } + + /// + /// Builds the `-c safe.directory=` arguments for the given working directory. Commands that do not + /// inherit `Commands.Command` should prepend the result to their own arguments, so that repositories + /// trusted for this session work there too. + /// + public static string GetSessionSafeDirectoryArgs(string workingDirectory) + { + var values = GetSessionSafeDirectories(workingDirectory); + if (values.Count == 0) + return string.Empty; + + var builder = new StringBuilder(); + foreach (var one in values) + builder.Append("-c safe.directory=").Append(one.Quoted()).Append(' '); + + return builder.ToString(); + } + + private static string Normalize(string path) + { + return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty; + } + + private static string ParseSuggestedValue(string output) + { + if (string.IsNullOrEmpty(output)) + return string.Empty; + + var match = REG_SAFE_DIRECTORY_HINT().Match(output); + if (!match.Success) + return string.Empty; + + return Dequote(match.Groups["value"].Value.Trim()); + } + + /// + /// Git quotes the suggested value with `sq_quote_buf()`, which escapes `'` and `!` as `'\''` and + /// `'\!'`. Decodes it back to the plain path (see `quote.c` of git). + /// + private static string Dequote(string value) + { + if (value.Length > 1 && value[0] == '\'') + { + var builder = new StringBuilder(value.Length); + for (var i = 1; i < value.Length; i++) + { + var c = value[i]; + if (c != '\'') + { + builder.Append(c); + continue; + } + + if (i == value.Length - 1) + return builder.ToString(); + + if (i + 3 < value.Length && value[i + 1] == '\\' && (value[i + 2] == '\'' || value[i + 2] == '!') && value[i + 3] == '\'') + { + builder.Append(value[i + 2]); + i += 3; + continue; + } + + return string.Empty; + } + + return string.Empty; + } + + if (value.Length > 1 && value[0] == '"' && value[^1] == '"') + return value[1..^1]; + + return value; + } + + private record TrustEntry(string WorkingDirectory, string Value); + + private static readonly StringComparison s_comparison = OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + private static readonly List s_sessionTrusted = []; + + [GeneratedRegex(@"--add\s+safe\.directory\s+(?[^\r\n]+)", RegexOptions.Multiline)] + private static partial Regex REG_SAFE_DIRECTORY_HINT(); + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 4dc15a4c36..b5cfdeb3b1 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -997,6 +997,15 @@ Merge ${0}$ into ${1}$... Push ${0}$... TERMINATE + Untrusted Repository + Will run: + Git refused to access this repository: + Trust this repository: + Path: + Trust permanently + {0} repositories were skipped because git does not trust them. Please open them manually and confirm whether to trust them. + Trust for this session only + This git version does not support trusting for this session only (requires git 2.38 or newer). Update Submodules All submodules Initialize as needed diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index a7f35949ba..7fd13e519f 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1001,6 +1001,15 @@ 合并 ${0}$ 到 ${1}$... 推送 ${0}$... 终止运行 + 不受信任的仓库 + 将执行: + Git 拒绝访问该仓库: + 信任方式: + 路径 : + 永久信任 + 有 {0} 个仓库因 git 的信任检查被跳过,请手动打开它们并确认是否信任。 + 仅本次信任 + 当前 git 版本不支持仅本次信任(需要 git 2.38 或更高版本)。 更新子模块 更新所有子模块 如未初始化子模块,先初始化 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 85612abe12..b3360df1ab 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -991,6 +991,15 @@ 合併 ${0}$ 到 ${1}$... 推送 ${0}$... 終止執行 + 不受信任的存放庫 + 將執行: + Git 拒絕存取該存放庫: + 信任方式: + 路徑 : + 永久信任 + 有 {0} 個存放庫因 git 的信任檢查而被略過,請手動開啟並確認是否信任。 + 僅本次信任 + 目前的 git 版本不支援僅本次信任(需要 git 2.38 或更新版本)。 更新子模組 更新所有子模組 如果子模組尚未初始化,則將其初始化 diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index 36b97c8984..eee1fee389 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -23,6 +23,7 @@ #FF898989 #FF1F1F1F #FF6F6F6F + #FFB36B00 #10000000 #80BFE6C1 #80FF9797 @@ -57,6 +58,7 @@ #FF4F4F4F #FFDFDFDF #FF9F9F9F + #FFE0A050 #3C000000 #C03A5C3F #C0633F3E @@ -91,6 +93,7 @@ + diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index 1cf00a848c..794776f9fa 100644 --- a/src/ViewModels/Launcher.cs +++ b/src/ViewModels/Launcher.cs @@ -104,7 +104,12 @@ public bool TryOpenRepositoryFromPath(string repo) if (ActivePage is not { Data: Welcome { }, Popup: null }) AddNewTab(); - ActivePage.Popup = new Init(ActivePage.Node.Id, repo, null, 0, test.StdErr ?? "Unknown error occurred while opening the repository."); + if (Models.SafeDirectories.IsUntrustedRepository(test.StdErr) && + Models.SafeDirectories.TryGetSafeDirectoryValue(repo, test.StdErr, out var safeDirectory)) + ActivePage.Popup = new TrustRepository(ActivePage.Node.Id, repo, test.StdErr, safeDirectory, null, false, true, 0); + else + ActivePage.Popup = new Init(ActivePage.Node.Id, repo, null, 0, test.StdErr ?? "Unknown error occurred while opening the repository."); + return true; } } @@ -325,6 +330,9 @@ public void OpenRepositoryInTab(RepositoryNode node, LauncherPage page) var gitDir = isBare ? node.Id : GetRepositoryGitDir(node.Id); if (string.IsNullOrEmpty(gitDir)) { + if (TryShowTrustRepositoryPopup(node.Id, ActivePage)) + return; + ActivePage.Notifications.Add(new Models.Notification { Group = node.Id, @@ -404,6 +412,9 @@ public void OpenSubRepository(LauncherPage ownerPage, string fullpath) var gitDir = GetRepositoryGitDir(normalizedPath); if (string.IsNullOrEmpty(gitDir)) { + if (TryShowTrustRepositoryPopup(normalizedPath, ownerPage)) + return; + ownerPage.Notifications.Add(new Models.Notification { Group = ownerPage.Node.Id, @@ -439,6 +450,21 @@ public void OpenSubRepository(LauncherPage ownerPage, string fullpath) ActivePage = page; } + private bool TryShowTrustRepositoryPopup(string path, LauncherPage page) + { + if (page == null || !page.CanCreatePopup()) + return false; + + var test = new Commands.QueryRepositoryRootPath(path).GetResult(); + if (test.IsSuccess || + !Models.SafeDirectories.IsUntrustedRepository(test.StdErr) || + !Models.SafeDirectories.TryGetSafeDirectoryValue(path, test.StdErr, out var safeDirectory)) + return false; + + page.Popup = new TrustRepository(page.Node.Id, path, test.StdErr, safeDirectory, null, false, true, 0); + return true; + } + private void DispatchNotification(Models.Notification notification) { if (!Dispatcher.UIThread.CheckAccess()) diff --git a/src/ViewModels/OpenLocalRepository.cs b/src/ViewModels/OpenLocalRepository.cs index 732c5c8acf..8636980b49 100644 --- a/src/ViewModels/OpenLocalRepository.cs +++ b/src/ViewModels/OpenLocalRepository.cs @@ -89,12 +89,27 @@ public override async Task Sure() else { var launcher = App.GetLauncher(); - foreach (var page in launcher.Pages) + if (Models.SafeDirectories.IsUntrustedRepository(test.StdErr) && + Models.SafeDirectories.TryGetSafeDirectoryValue(_repoPath, test.StdErr, out var safeDirectory)) { - if (page.Node.Id.Equals(_pageId, StringComparison.Ordinal)) + foreach (var page in launcher.Pages) { - page.Popup = new Init(page.Node.Id, _repoPath, parent, _bookmark, test.StdErr); - break; + if (page.Node.Id.Equals(_pageId, StringComparison.Ordinal)) + { + page.Popup = new TrustRepository(page.Node.Id, _repoPath, test.StdErr, safeDirectory, parent, true, true, _bookmark); + break; + } + } + } + else + { + foreach (var page in launcher.Pages) + { + if (page.Node.Id.Equals(_pageId, StringComparison.Ordinal)) + { + page.Popup = new Init(page.Node.Id, _repoPath, parent, _bookmark, test.StdErr); + break; + } } } diff --git a/src/ViewModels/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs index 544784c117..693c4ca415 100644 --- a/src/ViewModels/ScanRepositories.cs +++ b/src/ViewModels/ScanRepositories.cs @@ -115,6 +115,10 @@ public override async Task Sure() Preferences.Instance.AutoRemoveInvalidNode(); Preferences.Instance.Save(); Welcome.Instance.Refresh(); + + if (_untrusted > 0) + Models.Notification.Send(null, App.Text("TrustRepository.ScanSkipped", _untrusted)); + return true; } @@ -154,6 +158,10 @@ private async Task GetUnmanagedRepositoriesAsync(DirectoryInfo dir, List if (!IsManaged(normalized)) outs.Add(normalized); } + else if (Models.SafeDirectories.IsUntrustedRepository(test.StdErr)) + { + _untrusted++; + } continue; } @@ -182,5 +190,6 @@ private bool IsManaged(string path) private bool _useCustomDir = false; private string _customDir = string.Empty; private Models.ScanDir _selected = null; + private int _untrusted = 0; } } diff --git a/src/ViewModels/TrustRepository.cs b/src/ViewModels/TrustRepository.cs new file mode 100644 index 0000000000..a4cfbe4df7 --- /dev/null +++ b/src/ViewModels/TrustRepository.cs @@ -0,0 +1,131 @@ +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class TrustRepository : Popup + { + public string TargetPath + { + get; + } + + public string SafeDirectory + { + get; + } + + public string Command + { + get; + } + + public string Reason + { + get; + } + + public bool Permanent + { + get => _permanent; + set => SetProperty(ref _permanent, value); + } + + /// + /// `-c safe.directory=` (used by the session-only mode) is only respected by git 2.38 and later. + /// Older versions can only read the exception from the system/global config. + /// + public bool SupportsSessionTrust + { + get; + } + + public TrustRepository(string pageId, string path, string reason, string safeDirectory, RepositoryNode parent, bool moveNode, bool open, int bookmark) + { + _pageId = pageId; + _parent = parent; + SupportsSessionTrust = Models.SafeDirectories.SupportsSessionTrust(); + _permanent = !SupportsSessionTrust; + _moveNode = moveNode; + _open = open; + _bookmark = bookmark; + + TargetPath = path; + SafeDirectory = safeDirectory; + Command = $"git config --global --add safe.directory {safeDirectory.Quoted()}"; + Reason = GetReason(reason); + } + + public override async Task Sure() + { + var log = new CommandLog("Trust Repository"); + Use(log); + + if (Permanent) + { + ProgressDescription = $"Adding '{SafeDirectory}' into git global `safe.directory` ..."; + var added = await new Commands.AddSafeDirectory(_pageId, SafeDirectory).Use(log).ExecAsync(); + if (!added) + { + log.Complete(); + return false; + } + } + + // Always keep the exception in memory for this session, so the following commands + // can be executed without restarting the application. + Models.SafeDirectories.AddSessionTrust(TargetPath, SafeDirectory); + + ProgressDescription = $"Opening '{TargetPath}' ..."; + + var root = TargetPath; + var isBare = await new Commands.IsBareRepository(TargetPath).GetResultAsync(); + if (!isBare) + { + var test = await new Commands.QueryRepositoryRootPath(TargetPath).GetResultAsync(); + if (!test.IsSuccess || string.IsNullOrWhiteSpace(test.StdOut)) + { + log.Complete(); + Models.Notification.Send(_pageId, string.IsNullOrWhiteSpace(test.StdErr) ? "Failed to open the repository after trusting it." : test.StdErr, true); + return false; + } + + root = test.StdOut.Trim(); + } + + log.Complete(); + Models.SafeDirectories.AddSessionTrust(root, SafeDirectory); + + var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(root, _parent, _moveNode); + node.Bookmark = _bookmark; + await node.UpdateStatusAsync(false, null); + Welcome.Instance.Refresh(); + + if (_open) + node.Open(); + + return true; + } + + private static string GetReason(string stderr) + { + if (string.IsNullOrWhiteSpace(stderr)) + return string.Empty; + + // Only the first line (e.g. "fatal: detected dubious ownership in repository at '...'") is shown. + // The remaining lines only contain the owner SIDs and git's suggested `git config` command. + var line = stderr.Trim(); + var idx = line.IndexOf('\n'); + if (idx >= 0) + line = line.Substring(0, idx); + + return line.Trim(); + } + + private readonly string _pageId; + private readonly RepositoryNode _parent; + private readonly bool _moveNode; + private readonly bool _open; + private readonly int _bookmark; + private bool _permanent = false; + } +} diff --git a/src/ViewModels/Welcome.cs b/src/ViewModels/Welcome.cs index 49539292d7..24ce2ce31f 100644 --- a/src/ViewModels/Welcome.cs +++ b/src/ViewModels/Welcome.cs @@ -100,7 +100,7 @@ public void ToggleNodeIsExpanded(RepositoryNode node) } } - public async Task GetRepositoryRootAsync(string path) + public async Task GetRepositoryRootAsync(string path, RepositoryNode parent = null) { if (!Preferences.Instance.IsGitConfigured()) { @@ -122,10 +122,18 @@ public async Task GetRepositoryRootAsync(string path) return root; var rs = await new Commands.QueryRepositoryRootPath(root).GetResultAsync(); - if (!rs.IsSuccess || string.IsNullOrWhiteSpace(rs.StdOut)) - return null; + if (rs.IsSuccess && !string.IsNullOrWhiteSpace(rs.StdOut)) + return rs.StdOut.Trim(); - return rs.StdOut.Trim(); + if (Models.SafeDirectories.IsUntrustedRepository(rs.StdErr) && + Models.SafeDirectories.TryGetSafeDirectoryValue(root, rs.StdErr, out var safeDirectory)) + { + var launcher = App.GetLauncher(); + if (launcher?.ActivePage is { } page && page.CanCreatePopup()) + page.Popup = new TrustRepository(page.Node.Id, root, rs.StdErr, safeDirectory, parent, true, false, 0); + } + + return null; } public async Task AddRepositoryAsync(string path, RepositoryNode parent, bool moveNode, bool open) diff --git a/src/Views/TrustRepository.axaml b/src/Views/TrustRepository.axaml new file mode 100644 index 0000000000..552d83fce6 --- /dev/null +++ b/src/Views/TrustRepository.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TrustRepository.axaml.cs b/src/Views/TrustRepository.axaml.cs new file mode 100644 index 0000000000..5f624bc238 --- /dev/null +++ b/src/Views/TrustRepository.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class TrustRepository : UserControl + { + public TrustRepository() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Welcome.axaml.cs b/src/Views/Welcome.axaml.cs index c93c27ebb8..fbc3c193ab 100644 --- a/src/Views/Welcome.axaml.cs +++ b/src/Views/Welcome.axaml.cs @@ -394,7 +394,7 @@ private async void DropOnTreeNode(object sender, DragEventArgs e) foreach (var item in items) { - var path = await ViewModels.Welcome.Instance.GetRepositoryRootAsync(item.Path.LocalPath); + var path = await ViewModels.Welcome.Instance.GetRepositoryRootAsync(item.Path.LocalPath, to); if (!string.IsNullOrEmpty(path)) { await ViewModels.Welcome.Instance.AddRepositoryAsync(path, to, true, false); From 203cbac6763fae5109240b355e42384ad175d323 Mon Sep 17 00:00:00 2001 From: liccode Date: Mon, 28 Sep 2026 21:43:45 +0800 Subject: [PATCH 2/2] refactor: keep only permanent trust for untrusted repositories --- src/Commands/Command.cs | 3 -- src/Commands/QueryFileContent.cs | 4 +- src/Commands/SaveChangesAsPatch.cs | 2 +- src/Commands/SaveRevisionFile.cs | 2 +- src/Commands/UpdateIndexInfo.cs | 2 +- src/Models/GitVersions.cs | 6 --- src/Models/SafeDirectories.cs | 85 +----------------------------- src/Resources/Locales/en_US.axaml | 5 +- src/Resources/Locales/zh_CN.axaml | 5 +- src/Resources/Locales/zh_TW.axaml | 5 +- src/Resources/Themes.axaml | 3 -- src/ViewModels/LauncherPage.cs | 2 +- src/ViewModels/Popup.cs | 6 +++ src/ViewModels/TrustRepository.cs | 44 +++++++--------- src/Views/LauncherPage.axaml | 1 + src/Views/TrustRepository.axaml | 22 ++------ 16 files changed, 39 insertions(+), 158 deletions(-) diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 44afa07e57..14430805c2 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -183,9 +183,6 @@ protected ProcessStartInfo CreateGitStartInfo(bool redirect) break; } - // Session-only `safe.directory` exceptions. See `Models.SafeDirectories` for details. - builder.Append(Models.SafeDirectories.GetSessionSafeDirectoryArgs(WorkingDirectory)); - builder.Append(Args); var start = new ProcessStartInfo(); diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index 8d7732cc94..d111b5f210 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -12,7 +12,7 @@ public static async Task RunAsync(string repo, string revision, string f var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + $"show {revision}:{file.Quoted()}"; + starter.Arguments = $"show {revision}:{file.Quoted()}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; @@ -39,7 +39,7 @@ public static async Task FromLFSAsync(string repo, string oid, long size var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + "lfs smudge"; + starter.Arguments = "lfs smudge"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs index 2f7842535c..4ec83a6a63 100644 --- a/src/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -54,7 +54,7 @@ private static async Task ProcessSingleChangeAsync(string repo, Models.Dif var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + $"diff --no-color --no-ext-diff --ignore-cr-at-eol --unified=4 {opt}"; + starter.Arguments = $"diff --no-color --no-ext-diff --ignore-cr-at-eol --unified=4 {opt}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs index 9ad1206cca..6c810289d1 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -30,7 +30,7 @@ private static async Task ExecCmdAsync(string repo, string args, string outputFi var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(repo) + args; + starter.Arguments = args; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Commands/UpdateIndexInfo.cs b/src/Commands/UpdateIndexInfo.cs index dbf133d382..d8c47ca91c 100644 --- a/src/Commands/UpdateIndexInfo.cs +++ b/src/Commands/UpdateIndexInfo.cs @@ -53,7 +53,7 @@ public async Task ExecAsync() var starter = new ProcessStartInfo(); starter.WorkingDirectory = _repo; starter.FileName = Native.OS.GitExecutable; - starter.Arguments = Models.SafeDirectories.GetSessionSafeDirectoryArgs(_repo) + "-c core.editor=true update-index --index-info"; + starter.Arguments = "-c core.editor=true update-index --index-info"; starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; diff --git a/src/Models/GitVersions.cs b/src/Models/GitVersions.cs index ea625338b9..71fb4657ec 100644 --- a/src/Models/GitVersions.cs +++ b/src/Models/GitVersions.cs @@ -17,12 +17,6 @@ public static class GitVersions /// public static readonly System.Version STASH_PUSH_ONLY_STAGED = new(2, 35, 0); - /// - /// The minimal version of Git that respects the `safe.directory` exception passed with the `-c` - /// command line option. Older versions can only read the exception from the system/global config. - /// - public static readonly System.Version SAFE_DIRECTORY_COMMAND_LINE = new(2, 38, 0); - /// /// The minimal version of Git that supports the `git merge-tree --write-tree` command, which is used for testing merge results without actually performing a merge. /// diff --git a/src/Models/SafeDirectories.cs b/src/Models/SafeDirectories.cs index 87e3ff169f..5fcf24a3af 100644 --- a/src/Models/SafeDirectories.cs +++ b/src/Models/SafeDirectories.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System; using System.Text; using System.Text.RegularExpressions; @@ -21,15 +20,6 @@ public static bool IsUntrustedRepository(string output) output.Contains("unsafe repository", StringComparison.OrdinalIgnoreCase); } - /// - /// `-c safe.directory=` is only respected by git 2.38 and later. Older versions (2.35.2 - 2.37.x) - /// can only read the exception from the system/global config. - /// - public static bool SupportsSessionTrust() - { - return Native.OS.GitVersion >= GitVersions.SAFE_DIRECTORY_COMMAND_LINE; - } - /// /// Tries to get the value that should be written into the `safe.directory` config. /// Prefers the value suggested by git itself (which knows the correct form for the current platform), @@ -50,74 +40,6 @@ public static bool TryGetSafeDirectoryValue(string path, string output, out stri return true; } - /// - /// Trusts a directory only for the current session. The exception will be passed to git with - /// `-c safe.directory=` for every command executed under that directory, but it will - /// NOT be persisted into the user's git config. - /// - public static void AddSessionTrust(string workingDirectory, string safeDirectory) - { - var normalized = Normalize(workingDirectory); - if (string.IsNullOrEmpty(normalized) || string.IsNullOrEmpty(safeDirectory)) - return; - - lock (s_sessionTrusted) - { - foreach (var one in s_sessionTrusted) - { - if (one.WorkingDirectory.Equals(normalized, s_comparison) && one.Value.Equals(safeDirectory, StringComparison.Ordinal)) - return; - } - - s_sessionTrusted.Add(new TrustEntry(normalized, safeDirectory)); - } - } - - public static List GetSessionSafeDirectories(string workingDirectory) - { - var outs = new List(); - var normalized = Normalize(workingDirectory); - if (string.IsNullOrEmpty(normalized)) - return outs; - - var seen = new HashSet(StringComparer.Ordinal); - lock (s_sessionTrusted) - { - foreach (var one in s_sessionTrusted) - { - if (seen.Contains(one.Value)) - continue; - - if (normalized.Equals(one.WorkingDirectory, s_comparison) || - normalized.StartsWith(one.WorkingDirectory + "/", s_comparison)) - { - seen.Add(one.Value); - outs.Add(one.Value); - } - } - } - - return outs; - } - - /// - /// Builds the `-c safe.directory=` arguments for the given working directory. Commands that do not - /// inherit `Commands.Command` should prepend the result to their own arguments, so that repositories - /// trusted for this session work there too. - /// - public static string GetSessionSafeDirectoryArgs(string workingDirectory) - { - var values = GetSessionSafeDirectories(workingDirectory); - if (values.Count == 0) - return string.Empty; - - var builder = new StringBuilder(); - foreach (var one in values) - builder.Append("-c safe.directory=").Append(one.Quoted()).Append(' '); - - return builder.ToString(); - } - private static string Normalize(string path) { return path?.Replace('\\', '/').TrimEnd('/') ?? string.Empty; @@ -175,11 +97,6 @@ private static string Dequote(string value) return value; } - private record TrustEntry(string WorkingDirectory, string Value); - - private static readonly StringComparison s_comparison = OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - private static readonly List s_sessionTrusted = []; - [GeneratedRegex(@"--add\s+safe\.directory\s+(?[^\r\n]+)", RegexOptions.Multiline)] private static partial Regex REG_SAFE_DIRECTORY_HINT(); } diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index b5cfdeb3b1..d3a300dd0e 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1000,12 +1000,9 @@ Untrusted Repository Will run: Git refused to access this repository: - Trust this repository: Path: - Trust permanently + Trust this repository {0} repositories were skipped because git does not trust them. Please open them manually and confirm whether to trust them. - Trust for this session only - This git version does not support trusting for this session only (requires git 2.38 or newer). Update Submodules All submodules Initialize as needed diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 7fd13e519f..4f0ddd13d3 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1004,12 +1004,9 @@ 不受信任的仓库 将执行: Git 拒绝访问该仓库: - 信任方式: 路径 : - 永久信任 + 信任此仓库 有 {0} 个仓库因 git 的信任检查被跳过,请手动打开它们并确认是否信任。 - 仅本次信任 - 当前 git 版本不支持仅本次信任(需要 git 2.38 或更高版本)。 更新子模块 更新所有子模块 如未初始化子模块,先初始化 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index b3360df1ab..8286fc84c0 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -994,12 +994,9 @@ 不受信任的存放庫 將執行: Git 拒絕存取該存放庫: - 信任方式: 路徑 : - 永久信任 + 信任此存放庫 有 {0} 個存放庫因 git 的信任檢查而被略過,請手動開啟並確認是否信任。 - 僅本次信任 - 目前的 git 版本不支援僅本次信任(需要 git 2.38 或更新版本)。 更新子模組 更新所有子模組 如果子模組尚未初始化,則將其初始化 diff --git a/src/Resources/Themes.axaml b/src/Resources/Themes.axaml index eee1fee389..36b97c8984 100644 --- a/src/Resources/Themes.axaml +++ b/src/Resources/Themes.axaml @@ -23,7 +23,6 @@ #FF898989 #FF1F1F1F #FF6F6F6F - #FFB36B00 #10000000 #80BFE6C1 #80FF9797 @@ -58,7 +57,6 @@ #FF4F4F4F #FFDFDFDF #FF9F9F9F - #FFE0A050 #3C000000 #C03A5C3F #C0633F3E @@ -93,7 +91,6 @@ - diff --git a/src/ViewModels/LauncherPage.cs b/src/ViewModels/LauncherPage.cs index 370e5a25c7..73013a4889 100644 --- a/src/ViewModels/LauncherPage.cs +++ b/src/ViewModels/LauncherPage.cs @@ -80,7 +80,7 @@ public bool CanCreatePopup() public async Task ProcessPopupAsync() { - if (_popup is { InProgress: false } dump) + if (_popup is { InProgress: false } dump && dump.CanSure) { if (!dump.Check()) return; diff --git a/src/ViewModels/Popup.cs b/src/ViewModels/Popup.cs index 10bc44fe1b..325f2119ad 100644 --- a/src/ViewModels/Popup.cs +++ b/src/ViewModels/Popup.cs @@ -55,6 +55,12 @@ public virtual bool CanStartDirectly() return true; } + /// + /// Whether the `Sure` action is currently allowed. Views should bind the confirm button's + /// `IsEnabled` to this property. + /// + public virtual bool CanSure => true; + public virtual Task Sure() { return null; diff --git a/src/ViewModels/TrustRepository.cs b/src/ViewModels/TrustRepository.cs index a4cfbe4df7..ec0a4d2ca4 100644 --- a/src/ViewModels/TrustRepository.cs +++ b/src/ViewModels/TrustRepository.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; namespace SourceGit.ViewModels { @@ -24,27 +24,26 @@ public string Reason get; } + /// + /// Whether the exception should be added into the user's global git config. Disabled by default, + /// so the user has to opt in explicitly. + /// public bool Permanent { get => _permanent; - set => SetProperty(ref _permanent, value); + set + { + if (SetProperty(ref _permanent, value)) + OnPropertyChanged(nameof(CanSure)); + } } - /// - /// `-c safe.directory=` (used by the session-only mode) is only respected by git 2.38 and later. - /// Older versions can only read the exception from the system/global config. - /// - public bool SupportsSessionTrust - { - get; - } + public override bool CanSure => Permanent; public TrustRepository(string pageId, string path, string reason, string safeDirectory, RepositoryNode parent, bool moveNode, bool open, int bookmark) { _pageId = pageId; _parent = parent; - SupportsSessionTrust = Models.SafeDirectories.SupportsSessionTrust(); - _permanent = !SupportsSessionTrust; _moveNode = moveNode; _open = open; _bookmark = bookmark; @@ -57,24 +56,20 @@ public TrustRepository(string pageId, string path, string reason, string safeDir public override async Task Sure() { + if (!Permanent) + return false; + var log = new CommandLog("Trust Repository"); Use(log); - if (Permanent) + ProgressDescription = $"Adding '{SafeDirectory}' into git global `safe.directory` ..."; + var added = await new Commands.AddSafeDirectory(_pageId, SafeDirectory).Use(log).ExecAsync(); + if (!added) { - ProgressDescription = $"Adding '{SafeDirectory}' into git global `safe.directory` ..."; - var added = await new Commands.AddSafeDirectory(_pageId, SafeDirectory).Use(log).ExecAsync(); - if (!added) - { - log.Complete(); - return false; - } + log.Complete(); + return false; } - // Always keep the exception in memory for this session, so the following commands - // can be executed without restarting the application. - Models.SafeDirectories.AddSessionTrust(TargetPath, SafeDirectory); - ProgressDescription = $"Opening '{TargetPath}' ..."; var root = TargetPath; @@ -93,7 +88,6 @@ public override async Task Sure() } log.Complete(); - Models.SafeDirectories.AddSessionTrust(root, SafeDirectory); var node = Preferences.Instance.FindOrAddNodeByRepositoryPath(root, _parent, _moveNode); node.Bookmark = _bookmark; diff --git a/src/Views/LauncherPage.axaml b/src/Views/LauncherPage.axaml index 08107bfa90..a5390f327e 100644 --- a/src/Views/LauncherPage.axaml +++ b/src/Views/LauncherPage.axaml @@ -116,6 +116,7 @@ Padding="0" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" + IsEnabled="{Binding CanSure}" Content="{DynamicResource Text.Sure}" Click="OnPopupSure" ToolTip.Tip="Enter"/> diff --git a/src/Views/TrustRepository.axaml b/src/Views/TrustRepository.axaml index 552d83fce6..4706d1fe62 100644 --- a/src/Views/TrustRepository.axaml +++ b/src/Views/TrustRepository.axaml @@ -35,27 +35,11 @@ - - - - - + - +