Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ image = { version = "0.25.9", default-features = false, features = ["png"] }
landlock = "0.4.4"
libsqlite3-sys = { version = ">=0.30.1,<0.36.0", features = [ "bundled" ]}
miette = { version = "7.6.0", features = ["fancy"] }
nix = { version = "0.30.1", features = ["ioctl", "term", "user"] }
nix = { version = "0.30.1", features = ["fs", "ioctl", "term", "user"] }
percent-encoding = "2.3.2"
rayon = "1.11.0"
regex = { version = "1.12.2", default-features = false, features = [
Expand Down
70 changes: 64 additions & 6 deletions crates/soar-cli/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
time::Duration,
};

use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
Expand All @@ -25,13 +26,14 @@ use soar_core::{
SoarResult,
};
use soar_db::repository::{
core::{CoreRepository, SortDirection},
core::{CoreRepository, InstalledPackageWithPortable, SortDirection},
metadata::MetadataRepository,
};
use soar_dl::types::Progress;
use soar_package::integrate_package;
use soar_utils::{
hash::{calculate_checksum, hash_string},
lock::FileLock,
pattern::apply_sig_variants,
};
use tabled::{
Expand Down Expand Up @@ -1038,11 +1040,14 @@ async fn spawn_installation_task(

match result {
Ok((install_dir, symlinks)) => {
installed_indices
.lock()
.unwrap()
.insert(idx, (install_dir, symlinks));
installed_count.fetch_add(1, Ordering::Relaxed);
// Only count as installed if actually installed (not skipped)
if !install_dir.as_os_str().is_empty() {
installed_indices
.lock()
.unwrap()
.insert(idx, (install_dir, symlinks));
installed_count.fetch_add(1, Ordering::Relaxed);
}
total_pb.inc(1);

let _ = remove_old_versions(&target.package, &core_db, false);
Expand Down Expand Up @@ -1085,6 +1090,59 @@ pub async fn install_single_package(
target.package.repo_name,
target.package.version
);

let mut lock_attempts = 0;
let _package_lock = loop {
match FileLock::try_acquire(&target.package.pkg_name) {
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the FileLock implementation
find . -name "lock.rs" -o -name "*lock*" | grep -E "\.rs$" | head -10

Repository: pkgforge/soar

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Check the install.rs context around line 1096
sed -n '1085,1110p' crates/soar-cli/src/install.rs

Repository: pkgforge/soar

Length of output: 968


🏁 Script executed:

#!/bin/bash
# Search for FileLock::try_acquire usage to understand the pattern
rg -n 'FileLock::try_acquire' --type rust -B2 -A2

Repository: pkgforge/soar

Length of output: 1125


🏁 Script executed:

#!/bin/bash
# Check if pkg_name is unique across repos or if composite keys are used elsewhere
rg -n '(repo_name|pkg_name|pkg_id)' crates/soar-utils/src/ --type rust | head -30

Repository: pkgforge/soar

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Read the FileLock implementation
cat -n crates/soar-utils/src/lock.rs

Repository: pkgforge/soar

Length of output: 6806


🏁 Script executed:

#!/bin/bash
# Search for package structures and how repo_name is associated with pkg_name
rg -n 'pub struct Package|struct Package' --type rust -A10 crates/soar-utils/src/

Repository: pkgforge/soar

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Check if pkg_name is guaranteed unique across repos or if there's documentation
rg -n 'pkg_name.*unique|unique.*pkg_name' --type rust -i

Repository: pkgforge/soar

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Search for how packages are stored/queried to understand if pkg_name is unique within repos only
rg -n 'repo_name.*pkg_name|pkg_name.*repo_name' --type rust -B2 -A2 | head -50

Repository: pkgforge/soar

Length of output: 3306


🏁 Script executed:

#!/bin/bash
# Check the apply.rs file around the context mentioned in the learning
sed -n '140,160p' crates/soar-cli/src/apply.rs

Repository: pkgforge/soar

Length of output: 698


🏁 Script executed:

#!/bin/bash
# Search for package query patterns to see how uniqueness is handled
rg -n 'WHERE|SELECT' --type rust -B1 -A1 | grep -E 'repo_name|pkg_name|pkg_id' | head -20

Repository: pkgforge/soar

Length of output: 166


🏁 Script executed:

#!/bin/bash
# Check if there's documentation or tests showing packages with same name from different repos
rg -n 'different.*repo|multiple.*repo' --type rust -i -C2 | head -30

Repository: pkgforge/soar

Length of output: 1896


Consider using a composite lock key if same package names can exist across different repositories.

The lock uses only pkg_name, but packages throughout the codebase are identified by the composite tuple (repo_name, pkg_name, pkg_id). Database queries and deduplication logic all use this composite key pattern. If the same pkg_name exists in different repositories, they'll unnecessarily serialize on a shared lock file. If this scenario is rare/acceptable, it's fine—but for consistency and to prevent unnecessary contention, consider using a composite key like format!("{}:{}:{}", repo_name, pkg_name, pkg_id) for the lock.

🤖 Prompt for AI Agents
In `@crates/soar-cli/src/install.rs` at line 1096, The lock currently calls
FileLock::try_acquire(&target.package.pkg_name) which only uses pkg_name; change
it to use a composite key built from the package identity used elsewhere
(repo_name, pkg_name, pkg_id) so locks are unique per package instance. Locate
the call to FileLock::try_acquire in install.rs (the line with
FileLock::try_acquire(&target.package.pkg_name)) and replace the argument with a
formatted composite string like format!("{repo}:{name}:{id}") using
target.repository.repo_name (or equivalent), target.package.pkg_name, and
target.package.pkg_id to construct the lock key. Ensure the composite string is
deterministic and used wherever package-level locking is expected so different
repos with the same pkg_name do not contend on the same lock.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though the different pkg_id or repo_name is used, they might symlink as a same binary resulting in conflicts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@QaidVoid, that's a great point! You're absolutely right. Using pkg_name as the lock key makes sense because different package variants (different pkg_id or repo_name) that share the same pkg_name will ultimately create symlinks with the same binary names in the bin directory. If they installed concurrently with a more granular lock, they'd race on those symlink creations.

The current implementation correctly serializes installations at the pkg_name level to prevent these symlink conflicts. Thank you for the clarification!


✏️ Learnings added
Learnt from: QaidVoid
Repo: pkgforge/soar PR: 154
File: crates/soar-cli/src/install.rs:1096-1096
Timestamp: 2026-02-11T16:23:54.342Z
Learning: In crates/soar-cli/src/install.rs, FileLock uses only pkg_name (not a composite key with repo_name/pkg_id) intentionally. Different package variants with the same pkg_name but different pkg_id or repo_name will symlink to the same binary names in the bin directory. Locking at the pkg_name level prevents symlink race conditions during concurrent installations of different variants.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: QaidVoid
Repo: pkgforge/soar PR: 137
File: crates/soar-cli/src/apply.rs:149-153
Timestamp: 2026-01-09T16:14:21.516Z
Learning: In crates/soar-cli/src/apply.rs, for URL packages, passing non-installed records (is_installed=false) as existing_install is intentional. The query filters by repo_name, pkg_name, and pkg_id, ensuring the record is for the same package. This allows resuming partial installs and preserving settings (portable paths, profile) from previous installs.

Ok(Some(lock)) => break Ok(lock),
Ok(None) => {
lock_attempts += 1;
if lock_attempts == 1 {
info!(
"Waiting for lock on '{}' (another process is installing)...",
target.package.pkg_name
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(err) => {
break Err(err);
}
}
}
.map_err(|e| SoarError::Custom(format!("Failed to acquire package lock: {}", e)))?;

debug!(
"acquired lock for '{}' after {} attempts",
target.package.pkg_name, lock_attempts
);

// Re-check if package is already installed after acquiring lock
let freshly_installed: Option<InstalledPackageWithPortable> = core_db
.with_conn(|conn| {
CoreRepository::list_filtered(
conn,
Some(&target.package.repo_name),
Some(&target.package.pkg_name),
Some(&target.package.pkg_id),
Some(&target.package.version),
Some(true),
None,
None,
Some(SortDirection::Asc),
)
})?
.into_iter()
.find(|ip| ip.is_installed);

if let Some(ref pkg) = freshly_installed {
info!(
"{}#{}:{} ({}) is already installed - skipping",
pkg.pkg_name, pkg.pkg_id, pkg.repo_name, pkg.version
);
return Ok((PathBuf::new(), Vec::new()));
}

let bin_dir = get_config().get_bin_path()?;

let dir_suffix: String = target
Expand Down
23 changes: 23 additions & 0 deletions crates/soar-utils/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ pub enum HashError {
},
}

/// Errors that can occur when working with locks.
#[derive(Debug, Diagnostic, Error)]
pub enum LockError {
#[error(transparent)]
#[diagnostic(
code(soar_utils::lock::io),
help("Check if you have write permissions to the lock directory")
)]
Io(#[from] std::io::Error),

#[error("Failed to acquire lock for '{0}'")]
#[diagnostic(
code(soar_utils::lock::acquire_failed),
help("Check if the lock directory exists and you have write permissions")
)]
AcquireFailed(String),
}

/// Error type for path operations.
#[derive(Error, Diagnostic, Debug)]
pub enum PathError {
Expand Down Expand Up @@ -360,6 +378,10 @@ pub enum UtilsError {
#[diagnostic(transparent)]
Bytes(#[from] BytesError),

#[error(transparent)]
#[diagnostic(transparent)]
Lock(#[from] LockError),

#[error(transparent)]
#[diagnostic(transparent)]
Path(#[from] PathError),
Expand All @@ -376,6 +398,7 @@ pub enum UtilsError {
pub type BytesResult<T> = std::result::Result<T, BytesError>;
pub type FileSystemResult<T> = std::result::Result<T, FileSystemError>;
pub type HashResult<T> = std::result::Result<T, HashError>;
pub type LockResult<T> = std::result::Result<T, LockError>;
pub type PathResult<T> = std::result::Result<T, PathError>;
pub type UtilsResult<T> = std::result::Result<T, UtilsError>;

Expand Down
2 changes: 1 addition & 1 deletion crates/soar-utils/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub fn safe_remove<P: AsRef<Path>>(path: P) -> FileSystemResult<()> {
return Err(FileSystemError::RemoveFile {
path: path.to_path_buf(),
source: e,
})
});
}
};

Expand Down
1 change: 1 addition & 0 deletions crates/soar-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod bytes;
pub mod error;
pub mod fs;
pub mod hash;
pub mod lock;
pub mod path;
pub mod pattern;
pub mod system;
Expand Down
193 changes: 193 additions & 0 deletions crates/soar-utils/src/lock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
//! File-based locking mechanism for preventing concurrent operations.
//!
//! This module provides a simple file-based lock using a `.lock` file to ensure
//! that only one process can operate on a specific resource at a time.

use std::{
fs::{self, File, OpenOptions},
path::{Path, PathBuf},
};

use crate::error::{LockError, LockResult};

/// A file-based lock using `flock`.
///
/// The lock is automatically released when `FileLock` is dropped.
pub struct FileLock {
_file: nix::fcntl::Flock<File>,
path: PathBuf,
}

impl FileLock {
/// Get the default lock directory for soar.
///
/// Uses `$XDG_RUNTIME_DIR/soar/locks` or falls back to `/tmp/soar-locks`.
fn lock_dir() -> LockResult<PathBuf> {
let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
let base = if let Some(ref runtime) = xdg_runtime {
PathBuf::from(runtime)
} else {
std::env::temp_dir()
};

let lock_dir = base.join("soar").join("locks");

if !lock_dir.exists() {
fs::create_dir_all(&lock_dir)?;
}

Ok(lock_dir)
}
Comment on lines +25 to +40
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback lock directory in /tmp is world-writable — potential for cross-user interference.

When XDG_RUNTIME_DIR is unset, the lock directory falls back to <temp_dir>/soar/locks (typically /tmp/soar/locks). Any user can create or hold lock files there, which could cause:

  • A denial-of-service where another user holds the lock indefinitely
  • Permission errors if the directory was created by a different user

Since soar is a per-user package manager, consider including the UID in the fallback path (e.g., /tmp/soar-{uid}/locks) to isolate users.

Proposed fix
     fn lock_dir() -> LockResult<PathBuf> {
         let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
         let base = if let Some(ref runtime) = xdg_runtime {
             PathBuf::from(runtime)
         } else {
-            std::env::temp_dir()
+            let uid = nix::unistd::getuid();
+            std::env::temp_dir().join(format!("soar-{}", uid))
         };
 
-        let lock_dir = base.join("soar").join("locks");
+        let lock_dir = base.join("soar").join("locks");
🤖 Prompt for AI Agents
In `@crates/soar-utils/src/lock.rs` around lines 25 - 40, The fallback in
lock_dir() uses temp_dir which is world-shared; change lock_dir() so that when
XDG_RUNTIME_DIR is None you append a per-user component (e.g., "soar-{uid}") to
the temp_dir before joining "locks" to isolate users; obtain the current UID
(via libc::getuid() or a crate helper) and incorporate it into the base path
used when std::env::var("XDG_RUNTIME_DIR") is None, then create that directory
as before (fs::create_dir_all) so locks are placed under a user-specific temp
subdirectory.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@QaidVoid, understood. I'll defer to your judgment on this.

(\_/)
( •_•)
/ >  ✓


/// Generate a lock file path for a package.
fn lock_path(name: &str) -> LockResult<PathBuf> {
let lock_dir = Self::lock_dir()?;

// Sanitize the package name to ensure a valid filename
let sanitize = |s: &str| {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect::<String>()
};
Comment on lines +46 to +57
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitization could cause lock collisions between distinct package names.

Characters outside [a-zA-Z0-9._-] are all mapped to _, so e.g. "pkg/foo" and "pkg@foo" would both produce "pkg_foo.lock". If package names can contain such characters, consider using a hash-based filename or a more collision-resistant encoding.

🤖 Prompt for AI Agents
In `@crates/soar-utils/src/lock.rs` around lines 46 - 57, The current sanitize
closure in lock.rs maps all non [a-zA-Z0-9._-] chars to '_' which can cause
distinct package names (e.g., "pkg/foo" vs "pkg@foo") to collide; update the
lock filename generation to append or replace the sanitized name with a short
collision-resistant digest (e.g., a hex or base32 hash of the original package
name) so filenames remain filesystem-safe but unique — locate the sanitize
closure and the code that builds the lock filename and change it to include a
hash of the original string (or use the hash as the filename with the sanitized
name as a prefix) to avoid collisions while preserving readability.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine. Package names shouldn't usually contain those characters.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@QaidVoid, understood! I've noted that for future reference.


let filename = format!("{}.lock", sanitize(name));
Ok(lock_dir.join(filename))
}

/// Acquire an exclusive lock on a package.
///
/// This will block until the lock can be acquired.
///
/// # Arguments
///
/// * `name` - Package name
///
/// # Returns
///
/// Returns a `FileLock` that will automatically release the lock when dropped.
pub fn acquire(name: &str) -> LockResult<Self> {
let lock_path = Self::lock_path(name)?;

let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;

let file = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err(
|(_, err)| LockError::AcquireFailed(format!("{}: {}", lock_path.display(), err)),
)?;

Ok(FileLock {
path: lock_path,
_file: file,
})
}

/// Try to acquire an exclusive lock without blocking.
///
/// Returns `None` if the lock is already held by another process.
///
/// # Arguments
///
/// * `name` - Package name
pub fn try_acquire(name: &str) -> LockResult<Option<Self>> {
let lock_path = Self::lock_path(name)?;

let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;

match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
Ok(file) => {
Ok(Some(FileLock {
path: lock_path,
_file: file,
}))
}
Err((_, err)) => {
if matches!(err, nix::errno::Errno::EWOULDBLOCK) {
return Ok(None);
}
Err(LockError::AcquireFailed(format!(
"{}: {}",
lock_path.display(),
err
)))
}
}
}

/// Get the path to the lock file.
pub fn path(&self) -> &Path {
&self.path
}
}

#[cfg(test)]
mod tests {
use std::{thread, time::Duration};

use super::*;

#[test]
fn test_lock_path_generation() {
let path = FileLock::lock_path("test-pkg").unwrap();
assert!(path.to_string_lossy().ends_with("test-pkg.lock"));
}

#[test]
fn test_lock_sanitization() {
let path = FileLock::lock_path("test/pkg").unwrap();
assert!(path.to_string_lossy().contains("test_pkg"));
}

#[test]
fn test_exclusive_lock() {
let lock1 = FileLock::acquire("test-exclusive").unwrap();

let lock2 = FileLock::try_acquire("test-exclusive").unwrap();
assert!(lock2.is_none(), "Should not be able to acquire lock");

drop(lock1);

let lock3 = FileLock::try_acquire("test-exclusive").unwrap();
assert!(
lock3.is_some(),
"Should be able to acquire lock after release"
);
}

#[test]
fn test_concurrent_locks_different_packages() {
let lock1 = FileLock::acquire("pkg-a").unwrap();
let lock2 = FileLock::acquire("pkg-b").unwrap();

assert!(lock1.path() != lock2.path());
}

#[test]
fn test_lock_blocks_until_released() {
let lock1 = FileLock::acquire("test-block").unwrap();
let path = lock1.path().to_path_buf();

let handle = thread::spawn(move || {
let lock2 = FileLock::acquire("test-block").unwrap();
assert_eq!(lock2.path(), &path);
});

thread::sleep(Duration::from_millis(100));

drop(lock1);

handle.join().unwrap();
}
}