Skip to content

Commit 98fcf4d

Browse files
committed
fix: avoid /etc/gitconfig under strict snap confinement
Strict snap confinement blocks access to /etc/gitconfig, which makes libgit2 fail to even open a non-bare repo with an error like '/etc/gitconfig' is locked: Permission denied, since opening a repo always loads the merged config including the system level. Detect strict confinement via the SNAP/SNAP_CONFINEMENT environment variables snapd sets and, in that case only, redirect libgit2's system-level config search path to the snap's own install directory (which never contains a gitconfig file), mirroring the approach the test suite already uses to avoid touching real system config files. Fixes #2984
1 parent bc086cf commit 98fcf4d

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
* crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895))
2121
* when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748))
2222
* index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953))
23+
* snap (strict confinement) builds failing to open any non-bare repo with `'/etc/gitconfig' is locked: Permission denied` ([#2984](https://github.com/gitui-org/gitui/issues/2984))
2324

2425
## [0.28.1] - 2026-03-21
2526

asyncgit/src/sync/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ pub use remotes::{
8686
tags::PushTagsProgress, update_remote_url, validate_remote_name,
8787
};
8888
pub(crate) use repository::{gix_repo, repo};
89-
pub use repository::{RepoPath, RepoPathRef};
89+
pub use repository::{
90+
sanitize_snap_config_search_path, RepoPath, RepoPathRef,
91+
};
9092
pub use reset::{reset_repo, reset_stage, reset_workdir};
9193
pub use reword::reword;
9294
pub use staging::{discard_lines, stage_lines};

asyncgit/src/sync/repository.rs

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use std::{
22
cell::RefCell,
3+
ffi::OsStr,
34
path::{Path, PathBuf},
45
};
56

6-
use git2::{Repository, RepositoryOpenFlags};
7+
use git2::{ConfigLevel, Repository, RepositoryOpenFlags};
78

89
use crate::error::Result;
910

@@ -68,6 +69,71 @@ pub fn repo(repo_path: &RepoPath) -> Result<Repository> {
6869
Ok(repo)
6970
}
7071

72+
/// Works around repo opening failing under strict snap confinement.
73+
///
74+
/// Strict snap confinement denies libgit2 access to `/etc/gitconfig`, and
75+
/// every repo open fails outright with something like `'/etc/gitconfig' is
76+
/// locked: Permission denied` (see [#2984]), because opening a repo always
77+
/// loads the system-level git config as part of the merged config.
78+
///
79+
/// This redirects libgit2's system-level config search path to the snap's
80+
/// own (read-only, always accessible) install directory, which never
81+
/// contains a `gitconfig` file, so libgit2 simply finds no system config
82+
/// instead of failing to reach `/etc`. It mirrors what the test suite
83+
/// already does to avoid touching real system config files, see
84+
/// `sandbox_config_files` in `sync::tests`.
85+
///
86+
/// Must be called once, as early as possible at startup, before any repo
87+
/// is opened.
88+
///
89+
/// [#2984]: https://github.com/gitui-org/gitui/issues/2984
90+
#[allow(unsafe_code)]
91+
pub fn sanitize_snap_config_search_path() {
92+
if let Some(path) = snap_system_config_override(
93+
std::env::var_os("SNAP").as_deref(),
94+
std::env::var_os("SNAP_CONFINEMENT").as_deref(),
95+
) {
96+
// SAFETY: `set_search_path` mutates process-global libgit2 state
97+
// and must not race with concurrent config reads. This function
98+
// is documented to run once, before any repo is opened, so no
99+
// other thread is touching libgit2 config state yet.
100+
unsafe {
101+
let _ = git2::opts::set_search_path(
102+
ConfigLevel::System,
103+
path,
104+
);
105+
}
106+
}
107+
}
108+
109+
/// Decides whether libgit2's system-level config search path needs to be
110+
/// redirected away from `/etc` to work around strict snap confinement, and
111+
/// if so, to what path.
112+
///
113+
/// Returns `None` when running unconfined, or under `classic`/`devmode`
114+
/// confinement, both of which can reach the real filesystem, so the
115+
/// default search path should be left untouched.
116+
fn snap_system_config_override(
117+
snap: Option<&OsStr>,
118+
snap_confinement: Option<&OsStr>,
119+
) -> Option<PathBuf> {
120+
let snap = snap?;
121+
122+
// `classic` and `devmode` confinement have (near-)unrestricted
123+
// filesystem access, so `/etc/gitconfig` is reachable there. Only
124+
// `strict` confinement - or older snapd releases that predate the
125+
// `SNAP_CONFINEMENT` variable and default to `strict` - need the
126+
// workaround.
127+
if matches!(
128+
snap_confinement.and_then(OsStr::to_str),
129+
Some("classic" | "devmode")
130+
) {
131+
return None;
132+
}
133+
134+
Some(PathBuf::from(snap))
135+
}
136+
71137
pub fn gix_repo(repo_path: &RepoPath) -> Result<gix::Repository> {
72138
let mut repo: gix::Repository = gix::ThreadSafeRepository::discover_with_environment_overrides(
73139
repo_path.gitpath(),
@@ -80,3 +146,67 @@ pub fn gix_repo(repo_path: &RepoPath) -> Result<gix::Repository> {
80146

81147
Ok(repo)
82148
}
149+
150+
#[cfg(test)]
151+
mod tests {
152+
use super::snap_system_config_override;
153+
use std::{ffi::OsStr, path::PathBuf};
154+
155+
#[test]
156+
fn test_snap_override_not_applied_outside_snap() {
157+
assert_eq!(snap_system_config_override(None, None), None);
158+
assert_eq!(
159+
snap_system_config_override(
160+
None,
161+
Some(OsStr::new("strict"))
162+
),
163+
None
164+
);
165+
}
166+
167+
#[test]
168+
fn test_snap_override_applied_under_strict_confinement() {
169+
assert_eq!(
170+
snap_system_config_override(
171+
Some(OsStr::new("/snap/gitui/380")),
172+
Some(OsStr::new("strict"))
173+
),
174+
Some(PathBuf::from("/snap/gitui/380"))
175+
);
176+
}
177+
178+
#[test]
179+
fn test_snap_override_applied_when_confinement_unknown() {
180+
// Older snapd releases don't set `SNAP_CONFINEMENT`; assume the
181+
// worst (strict) rather than risk failing to open the repo.
182+
assert_eq!(
183+
snap_system_config_override(
184+
Some(OsStr::new("/snap/gitui/380")),
185+
None
186+
),
187+
Some(PathBuf::from("/snap/gitui/380"))
188+
);
189+
}
190+
191+
#[test]
192+
fn test_snap_override_not_applied_under_classic_confinement() {
193+
assert_eq!(
194+
snap_system_config_override(
195+
Some(OsStr::new("/snap/gitui/380")),
196+
Some(OsStr::new("classic"))
197+
),
198+
None
199+
);
200+
}
201+
202+
#[test]
203+
fn test_snap_override_not_applied_under_devmode_confinement() {
204+
assert_eq!(
205+
snap_system_config_override(
206+
Some(OsStr::new("/snap/gitui/380")),
207+
Some(OsStr::new("devmode"))
208+
),
209+
None
210+
);
211+
}
212+
}

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ fn main() -> Result<()> {
163163

164164
let cliargs = process_cmdline()?;
165165

166+
asyncgit::sync::sanitize_snap_config_search_path();
167+
166168
asyncgit::register_tracing_logging();
167169
ensure_valid_path(&cliargs.repo_path)?;
168170

0 commit comments

Comments
 (0)