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
9 changes: 6 additions & 3 deletions docs/bootstrap/packages/pacman.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...).

## Behavior

- Package state is checked with `pacman -Q` (read-only, never elevates).
- Package state is checked with `pacman -Q` and `pacman -T` (read-only, never
elevates). An installed package that satisfies the requested name through
`Provides` counts as installed.
- Missing packages are installed with `pacman -S --noconfirm --needed`,
elevated with sudo when necessary (see
[sudo](/bootstrap/packages/#sudo)). `--needed` makes installs
Expand All @@ -19,8 +21,9 @@ System packages for Arch-family Linux (Arch, Manjaro, EndeavourOS, ...).
runs `pacman -Sy` automatically before installing. Force a refresh with
`mise bootstrap packages apply --update`.
- `mise bootstrap packages upgrade` runs `pacman -Sy` and then upgrades only the
configured packages. Note that Arch officially supports only full-system
upgrades (`pacman -Syu`) — upgrading individual packages is a
configured packages. Requests satisfied through `Provides` are skipped to
avoid replacing the installed provider. Note that Arch officially supports
only full-system upgrades (`pacman -Syu`) — upgrading individual packages is a
[partial upgrade](https://wiki.archlinux.org/title/System_maintenance#Partial_upgrades_are_unsupported),
so prefer running `pacman -Syu` yourself on a rolling-release system.

Expand Down
282 changes: 241 additions & 41 deletions src/system/packages/pacman.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Stdio;

Expand Down Expand Up @@ -48,21 +48,7 @@ fn parse_pacman_query(output: &str, requests: &[PackageRequest]) -> Vec<PackageS
.iter()
.map(|req| {
let state = match installed.get(req.name.as_str()) {
// a pin matches the full version-pkgrel or just the version
// part (any pkgrel)
Some(version) => match &req.version {
Some(requested)
if *version != requested.as_str()
&& !version.starts_with(&format!("{requested}-")) =>
{
PackageState::VersionMismatch {
installed: version.to_string(),
}
}
_ => PackageState::Installed {
version: version.to_string(),
},
},
Some(version) => package_state(req, version),
None => PackageState::Missing,
};
PackageStatus {
Expand All @@ -73,6 +59,122 @@ fn parse_pacman_query(output: &str, requests: &[PackageRequest]) -> Vec<PackageS
.collect()
}

fn package_state(req: &PackageRequest, version: &str) -> PackageState {
// a pin matches the full version-pkgrel or just the version part (any
// pkgrel)
match &req.version {
Some(requested)
if version != requested && !version.starts_with(&format!("{requested}-")) =>
{
PackageState::VersionMismatch {
installed: version.to_string(),
}
}
_ => PackageState::Installed {
version: version.to_string(),
},
}
}

fn parse_pacman_package(output: &str) -> Option<(&str, &str)> {
output.lines().find_map(|line| line.split_once(' '))
}

fn parse_pacman_deptest(output: &str) -> HashSet<&str> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect()
}

fn deptest_requirement(req: &PackageRequest) -> String {
match &req.version {
Some(version) => format!("{}={version}", req.name),
None => req.name.clone(),
}
}

fn apply_provider_query<'a>(
status: &mut PackageStatus,
output: &'a str,
constraint_satisfied: bool,
) -> Result<&'a str> {
let Some((provider, version)) = parse_pacman_package(output) else {
bail!(
"pacman -Q returned no package for satisfied requirement '{}'",
status.request.name
);
};
// The provider's package version is display metadata; pacman -T evaluates
// the requested version against the version declared in Provides.
status.state = if constraint_satisfied {
PackageState::Installed {
version: version.to_string(),
}
} else {
PackageState::VersionMismatch {
installed: version.to_string(),
}
};
Ok(provider)
}

async fn pacman_query(names: &[String]) -> Result<String> {
if names.is_empty() {
return Ok(String::new());
}
let mut args = vec!["-Q", "--"];
args.extend(names.iter().map(String::as_str));
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(&args)
// pacman localizes its messages via gettext, so the "was not found"
// check below only works against the untranslated output.
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
// pacman -Q exits 1 when any package is missing ("error: package 'x'
// was not found" on stderr); installed ones still print to stdout.
// Anything else on stderr (corrupt db, lock file) is a real error.
let stderr = String::from_utf8_lossy(&output.stderr);
let only_missing = !stderr.is_empty()
&& stderr
.lines()
.all(|line| line.trim().is_empty() || line.contains("was not found"));
if !output.status.success() && (output.status.code() != Some(1) || !only_missing) {
bail!("pacman -Q failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

async fn pacman_deptest(names: &[String]) -> Result<String> {
if names.is_empty() {
return Ok(String::new());
}
let mut args = vec!["-T", "--"];
args.extend(names.iter().map(String::as_str));
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(&args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
// deptest returns 127 when at least one requirement is unsatisfied and
// prints those requirements to stdout. Both 0 and 127 are normal.
if !matches!(output.status.code(), Some(0 | 127)) {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("pacman -T failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

#[async_trait(?Send)]
impl SystemPackageManager for PacmanManager {
fn name(&self) -> &str {
Expand All @@ -95,33 +197,56 @@ impl SystemPackageManager for PacmanManager {
if pkgs.is_empty() {
return Ok(vec![]);
}
let mut args = vec!["-Q".to_string()];
args.extend(pkgs.iter().map(|p| p.name.clone()));
debug!("$ pacman {}", args.join(" "));
let output = tokio::process::Command::new("pacman")
.args(&args)
// pacman localizes its messages via gettext, so the "was not
// found" check below only works against the untranslated output.
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
// pacman -Q exits 1 when any package is missing ("error: package 'x'
// was not found" on stderr); installed ones still print to stdout.
// Anything else on stderr (corrupt db, lock file) is a real error.
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success()
&& !stderr.is_empty()
&& !stderr
.lines()
.all(|l| l.trim().is_empty() || l.contains("was not found"))
let names = pkgs.iter().map(|pkg| pkg.name.clone()).collect::<Vec<_>>();
let stdout = pacman_query(&names).await?;
let mut statuses = parse_pacman_query(&stdout, pkgs);
let apparent_missing = statuses
.iter()
.filter(|status| matches!(status.state, PackageState::Missing))
.map(|status| deptest_requirement(&status.request))
.collect::<Vec<_>>();
if apparent_missing.is_empty() {
return Ok(statuses);
}

// A bare query may answer a virtual package target under the installed
// provider's real name. Deptest tells us which apparent misses are
// genuinely unsatisfied; query the provider-satisfied names one at a
// time so their returned versions can be associated positionally.
let deptest = pacman_deptest(&apparent_missing).await?;
let unsatisfied = parse_pacman_deptest(&deptest);
// A failed version constraint can still have a provider for the bare
// name. Distinguish that mismatch from a genuinely missing package.
let versioned_unsatisfied = statuses
.iter()
.filter(|status| {
status.request.version.is_some()
&& unsatisfied.contains(deptest_requirement(&status.request).as_str())
})
.map(|status| status.request.name.clone())
.collect::<Vec<_>>();
let bare_deptest = pacman_deptest(&versioned_unsatisfied).await?;
let bare_missing = parse_pacman_deptest(&bare_deptest);
for status in statuses
.iter_mut()
.filter(|status| matches!(status.state, PackageState::Missing))
{
bail!("pacman -Q failed: {}", stderr.trim());
let constraint_satisfied =
!unsatisfied.contains(deptest_requirement(&status.request).as_str());
if !constraint_satisfied
&& (status.request.version.is_none()
|| bare_missing.contains(status.request.name.as_str()))
{
continue;
}
let output = pacman_query(std::slice::from_ref(&status.request.name)).await?;
let provider = apply_provider_query(status, &output, constraint_satisfied)?;
debug!(
"pacman: {} is satisfied by installed provider {provider}",
status.request.name
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pacman_query(&stdout, pkgs))
Ok(statuses)
}

fn supports_version_pins(&self) -> bool {
Expand Down Expand Up @@ -157,6 +282,25 @@ impl SystemPackageManager for PacmanManager {
}

async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
let names = pkgs.iter().map(|pkg| pkg.name.clone()).collect::<Vec<_>>();
let stdout = pacman_query(&names).await?;
let installed_names = stdout
.lines()
.filter_map(|line| line.split_once(' ').map(|(name, _)| name))
.collect::<HashSet<_>>();
let pkgs = pkgs
.iter()
.filter(|pkg| installed_names.contains(pkg.name.as_str()))
.collect::<Vec<_>>();
let skipped = names.len() - pkgs.len();
if skipped > 0 {
warn!(
"pacman: {skipped} package(s) satisfied by an installed provider; skipping targeted upgrade"
);
}
if pkgs.is_empty() {
return Ok(());
}
// refresh sync DBs, then -S --needed upgrades exactly the named
// packages that are outdated. Note: Arch officially supports only
// full-system upgrades (-Syu); upgrading individual packages is a
Expand Down Expand Up @@ -221,4 +365,60 @@ mod tests {
}
);
}

#[test]
fn test_apply_provider_query() {
let mut status = PackageStatus {
request: req("mariadb-clients", Some("12.3.2")),
state: PackageState::Missing,
};

// pacman -T validated the version declared by Provides even though the
// provider package has a different version of its own.
let provider =
apply_provider_query(&mut status, "percona-server-clients 9.7.1_1-1\n", true).unwrap();

assert_eq!(provider, "percona-server-clients");
assert_eq!(
status.state,
PackageState::Installed {
version: "9.7.1_1-1".to_string()
}
);
}

#[test]
fn test_apply_provider_query_version_mismatch() {
let mut status = PackageStatus {
request: req("virtual-package", Some("2.0")),
state: PackageState::Missing,
};

apply_provider_query(&mut status, "provider-package 2.0-1\n", false).unwrap();

assert_eq!(
status.state,
PackageState::VersionMismatch {
installed: "2.0-1".to_string()
}
);
}

#[test]
fn test_deptest_requirement_includes_version() {
assert_eq!(
deptest_requirement(&req("virtual-package", Some("2.0"))),
"virtual-package=2.0"
);
assert_eq!(
deptest_requirement(&req("virtual-package", None)),
"virtual-package"
);
}

#[test]
fn test_parse_pacman_deptest() {
let missing = parse_pacman_deptest("missing-one\nmissing-two\n");
assert_eq!(missing, HashSet::from(["missing-one", "missing-two"]));
}
}
Loading