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);