diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..1e420ac466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) * 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)) * index-out-of-bounds panic when unstaging lines near the end of a diff ([#2953](https://github.com/gitui-org/gitui/issues/2953)) +* 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)) ## [0.28.1] - 2026-03-21 diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..b143eb5f09 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -86,7 +86,9 @@ pub use remotes::{ tags::PushTagsProgress, update_remote_url, validate_remote_name, }; pub(crate) use repository::{gix_repo, repo}; -pub use repository::{RepoPath, RepoPathRef}; +pub use repository::{ + sanitize_snap_config_search_path, RepoPath, RepoPathRef, +}; pub use reset::{reset_repo, reset_stage, reset_workdir}; pub use reword::reword; pub use staging::{discard_lines, stage_lines}; diff --git a/asyncgit/src/sync/repository.rs b/asyncgit/src/sync/repository.rs index c49795fb7c..44f20e4998 100644 --- a/asyncgit/src/sync/repository.rs +++ b/asyncgit/src/sync/repository.rs @@ -1,9 +1,10 @@ use std::{ cell::RefCell, + ffi::OsStr, path::{Path, PathBuf}, }; -use git2::{Repository, RepositoryOpenFlags}; +use git2::{ConfigLevel, Repository, RepositoryOpenFlags}; use crate::error::Result; @@ -68,6 +69,71 @@ pub fn repo(repo_path: &RepoPath) -> Result { Ok(repo) } +/// Works around repo opening failing under strict snap confinement. +/// +/// Strict snap confinement denies libgit2 access to `/etc/gitconfig`, and +/// every repo open fails outright with something like `'/etc/gitconfig' is +/// locked: Permission denied` (see [#2984]), because opening a repo always +/// loads the system-level git config as part of the merged config. +/// +/// This redirects libgit2's system-level config search path to the snap's +/// own (read-only, always accessible) install directory, which never +/// contains a `gitconfig` file, so libgit2 simply finds no system config +/// instead of failing to reach `/etc`. It mirrors what the test suite +/// already does to avoid touching real system config files, see +/// `sandbox_config_files` in `sync::tests`. +/// +/// Must be called once, as early as possible at startup, before any repo +/// is opened. +/// +/// [#2984]: https://github.com/gitui-org/gitui/issues/2984 +#[allow(unsafe_code)] +pub fn sanitize_snap_config_search_path() { + if let Some(path) = snap_system_config_override( + std::env::var_os("SNAP").as_deref(), + std::env::var_os("SNAP_CONFINEMENT").as_deref(), + ) { + // SAFETY: `set_search_path` mutates process-global libgit2 state + // and must not race with concurrent config reads. This function + // is documented to run once, before any repo is opened, so no + // other thread is touching libgit2 config state yet. + unsafe { + let _ = git2::opts::set_search_path( + ConfigLevel::System, + path, + ); + } + } +} + +/// Decides whether libgit2's system-level config search path needs to be +/// redirected away from `/etc` to work around strict snap confinement, and +/// if so, to what path. +/// +/// Returns `None` when running unconfined, or under `classic`/`devmode` +/// confinement, both of which can reach the real filesystem, so the +/// default search path should be left untouched. +fn snap_system_config_override( + snap: Option<&OsStr>, + snap_confinement: Option<&OsStr>, +) -> Option { + let snap = snap?; + + // `classic` and `devmode` confinement have (near-)unrestricted + // filesystem access, so `/etc/gitconfig` is reachable there. Only + // `strict` confinement - or older snapd releases that predate the + // `SNAP_CONFINEMENT` variable and default to `strict` - need the + // workaround. + if matches!( + snap_confinement.and_then(OsStr::to_str), + Some("classic" | "devmode") + ) { + return None; + } + + Some(PathBuf::from(snap)) +} + pub fn gix_repo(repo_path: &RepoPath) -> Result { let mut repo: gix::Repository = gix::ThreadSafeRepository::discover_with_environment_overrides( repo_path.gitpath(), @@ -80,3 +146,67 @@ pub fn gix_repo(repo_path: &RepoPath) -> Result { Ok(repo) } + +#[cfg(test)] +mod tests { + use super::snap_system_config_override; + use std::{ffi::OsStr, path::PathBuf}; + + #[test] + fn test_snap_override_not_applied_outside_snap() { + assert_eq!(snap_system_config_override(None, None), None); + assert_eq!( + snap_system_config_override( + None, + Some(OsStr::new("strict")) + ), + None + ); + } + + #[test] + fn test_snap_override_applied_under_strict_confinement() { + assert_eq!( + snap_system_config_override( + Some(OsStr::new("/snap/gitui/380")), + Some(OsStr::new("strict")) + ), + Some(PathBuf::from("/snap/gitui/380")) + ); + } + + #[test] + fn test_snap_override_applied_when_confinement_unknown() { + // Older snapd releases don't set `SNAP_CONFINEMENT`; assume the + // worst (strict) rather than risk failing to open the repo. + assert_eq!( + snap_system_config_override( + Some(OsStr::new("/snap/gitui/380")), + None + ), + Some(PathBuf::from("/snap/gitui/380")) + ); + } + + #[test] + fn test_snap_override_not_applied_under_classic_confinement() { + assert_eq!( + snap_system_config_override( + Some(OsStr::new("/snap/gitui/380")), + Some(OsStr::new("classic")) + ), + None + ); + } + + #[test] + fn test_snap_override_not_applied_under_devmode_confinement() { + assert_eq!( + snap_system_config_override( + Some(OsStr::new("/snap/gitui/380")), + Some(OsStr::new("devmode")) + ), + None + ); + } +} diff --git a/src/main.rs b/src/main.rs index fd662950a2..294200f047 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,6 +163,8 @@ fn main() -> Result<()> { let cliargs = process_cmdline()?; + asyncgit::sync::sanitize_snap_config_search_path(); + asyncgit::register_tracing_logging(); ensure_valid_path(&cliargs.repo_path)?;