diff --git a/src/App.axaml.cs b/src/App.axaml.cs index eef4684ce..66882b486 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 000000000..52e190db6 --- /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 ba68bb90d..14430805c 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -210,8 +210,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/Models/SafeDirectories.cs b/src/Models/SafeDirectories.cs new file mode 100644 index 000000000..5fcf24a3a --- /dev/null +++ b/src/Models/SafeDirectories.cs @@ -0,0 +1,103 @@ +using System; +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); + } + + /// + /// 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; + } + + 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; + } + + [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 4dc15a4c3..d3a300dd0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -997,6 +997,12 @@ Merge ${0}$ into ${1}$... Push ${0}$... TERMINATE + Untrusted Repository + Will run: + Git refused to access this repository: + Path: + Trust this repository + {0} repositories were skipped because git does not trust them. Please open them manually and confirm whether to trust them. Update Submodules All submodules Initialize as needed diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index a7f35949b..4f0ddd13d 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -1001,6 +1001,12 @@ 合并 ${0}$ 到 ${1}$... 推送 ${0}$... 终止运行 + 不受信任的仓库 + 将执行: + Git 拒绝访问该仓库: + 路径 : + 信任此仓库 + 有 {0} 个仓库因 git 的信任检查被跳过,请手动打开它们并确认是否信任。 更新子模块 更新所有子模块 如未初始化子模块,先初始化 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 85612abe1..8286fc84c 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -991,6 +991,12 @@ 合併 ${0}$ 到 ${1}$... 推送 ${0}$... 終止執行 + 不受信任的存放庫 + 將執行: + Git 拒絕存取該存放庫: + 路徑 : + 信任此存放庫 + 有 {0} 個存放庫因 git 的信任檢查而被略過,請手動開啟並確認是否信任。 更新子模組 更新所有子模組 如果子模組尚未初始化,則將其初始化 diff --git a/src/ViewModels/Launcher.cs b/src/ViewModels/Launcher.cs index 1cf00a848..794776f9f 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/LauncherPage.cs b/src/ViewModels/LauncherPage.cs index 370e5a25c..73013a488 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/OpenLocalRepository.cs b/src/ViewModels/OpenLocalRepository.cs index 732c5c8ac..8636980b4 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/Popup.cs b/src/ViewModels/Popup.cs index 10bc44fe1..325f2119a 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/ScanRepositories.cs b/src/ViewModels/ScanRepositories.cs index 544784c11..693c4ca41 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 000000000..ec0a4d2ca --- /dev/null +++ b/src/ViewModels/TrustRepository.cs @@ -0,0 +1,125 @@ +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; + } + + /// + /// 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 + { + if (SetProperty(ref _permanent, value)) + OnPropertyChanged(nameof(CanSure)); + } + } + + 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; + _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() + { + if (!Permanent) + return false; + + var log = new CommandLog("Trust Repository"); + Use(log); + + 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; + } + + 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(); + + 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 49539292d..24ce2ce31 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/LauncherPage.axaml b/src/Views/LauncherPage.axaml index 08107bfa9..a5390f327 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 new file mode 100644 index 000000000..4706d1fe6 --- /dev/null +++ b/src/Views/TrustRepository.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TrustRepository.axaml.cs b/src/Views/TrustRepository.axaml.cs new file mode 100644 index 000000000..5f624bc23 --- /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 c93c27ebb..fbc3c193a 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);