use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::error::GuixError;
use crate::parsers::sexp::{parse_channels_list, ChannelsList};
use crate::repl::Repl;
use crate::types::Channel;
#[derive(Debug, thiserror::Error)]
pub enum ChannelsError {
#[error("parse error{}: {message}", line_col_suffix(*line, *column))]
ParseError {
message: String,
line: Option<u32>,
column: Option<u32>,
},
#[error("eval error{}: {message}", line_col_suffix(*line, *column))]
EvalError {
message: String,
line: Option<u32>,
column: Option<u32>,
},
#[error("channel `{name}` already exists")]
DuplicateName { name: String },
#[error("channel `{name}` has no introduction — discovery-side guarantee")]
MissingIntroduction { name: String },
#[error("operation `{op}` not supported")]
UnsupportedOp { op: String },
#[error("channel `{name}` not found")]
NotFound { name: String },
#[error("channels.scm at {path} is store-managed (guix home / read-only). Set a writable source-path override.")]
StoreManaged { path: PathBuf },
#[error("channel name `{name}` contains characters that aren't valid in a Scheme symbol")]
InvalidName { name: String },
#[error("channels.scm not found at {path}")]
FileNotFound { path: PathBuf },
#[error("internal error: {0}")]
Internal(String),
#[error(transparent)]
Guix(#[from] GuixError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
fn line_col_suffix(line: Option<u32>, column: Option<u32>) -> String {
match (line, column) {
(Some(l), Some(c)) => format!(" at {l}:{c}"),
(Some(l), None) => format!(" at line {l}"),
_ => String::new(),
}
}
#[derive(Debug, Clone)]
pub struct ChannelsFile {
pub path: PathBuf,
pub list: ChannelsList,
pub raw: String,
pub is_store_managed: bool,
}
impl ChannelsFile {
pub async fn read(path_override: Option<&Path>) -> Result<Self, ChannelsError> {
let path = match path_override {
Some(p) => p.to_path_buf(),
None => default_path()?,
};
let read_path = path.clone();
let raw = match tokio::task::spawn_blocking(move || std::fs::read_to_string(&read_path))
.await
.map_err(|e| ChannelsError::Internal(format!("read task panicked: {e}")))?
{
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ChannelsError::FileNotFound { path });
}
Err(e) => return Err(ChannelsError::Io(e)),
};
let list = parse_channels_list(&raw).map_err(|e| match e {
GuixError::Parse(msg) => ChannelsError::ParseError {
message: msg,
line: None,
column: None,
},
other => ChannelsError::Guix(other),
})?;
let is_store_managed = resolves_into_store(&path);
Ok(ChannelsFile {
path,
list,
raw,
is_store_managed,
})
}
pub fn is_writable(&self) -> bool {
!self.is_store_managed
}
pub fn backup_path(&self) -> PathBuf {
self.path.with_extension("scm.bak")
}
pub async fn validate(repl: &Repl, source: &str) -> Result<(), ChannelsError> {
let escaped = scheme_quote_string(source);
let form = format!(
"(catch #t \
(lambda () \
(call-with-input-string {escaped} \
(lambda (port) \
(let loop () \
(let ((v (read port))) \
(if (eof-object? v) (list 'ok) (loop)))))) ) \
(lambda (key . args) \
(list 'error 'parse-error \
(format #f \"~a: ~a\" key args) #f #f)))"
);
let v = repl.eval_persistent(&form).await?;
match interpret_response(&v) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
pub async fn apply(&self, repl: &Repl, op: ChannelOp) -> Result<String, ChannelsError> {
self.preflight(&op)?;
let op_sexp = op.to_scheme_sexp();
let source_lit = scheme_quote_string(&self.raw);
let form = format!("(libguix-rs:apply-channel-op {source_lit} '{op_sexp})");
let v = repl.eval_persistent(&form).await?;
let s = interpret_response(&v)?;
Ok(s)
}
fn preflight(&self, op: &ChannelOp) -> Result<(), ChannelsError> {
match op {
ChannelOp::AddChannel(ch) => {
if !is_valid_channel_name(&ch.name) {
return Err(ChannelsError::InvalidName {
name: ch.name.clone(),
});
}
if ch.introduction_commit.is_none() || ch.introduction_fingerprint.is_none() {
return Err(ChannelsError::MissingIntroduction {
name: ch.name.clone(),
});
}
if self.list.channels().iter().any(|c| c.name == ch.name) {
return Err(ChannelsError::DuplicateName {
name: ch.name.clone(),
});
}
Ok(())
}
ChannelOp::RemoveChannelByName(name) => {
if name == "guix" {
return Err(ChannelsError::UnsupportedOp {
op: "remove `guix` channel".into(),
});
}
if !self.list.channels().iter().any(|c| c.name == *name) {
return Err(ChannelsError::NotFound { name: name.clone() });
}
Ok(())
}
}
}
pub async fn write_atomic(&self, content: &str) -> Result<(), ChannelsError> {
if self.is_store_managed {
return Err(ChannelsError::StoreManaged {
path: self.path.clone(),
});
}
let path = self.path.clone();
let bak_path = self.backup_path();
let content = content.to_owned();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::fs;
use std::io::Write as _;
let tmp_path = path.with_extension("scm.tmp");
let parent = path.parent().unwrap_or_else(|| Path::new("."));
{
let mut f = fs::File::create(&tmp_path)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
match fs::copy(&path, &bak_path) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
}
fs::rename(&tmp_path, &path)?;
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
})
.await
.map_err(|e| ChannelsError::Internal(format!("write task panicked: {e}")))??;
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum ChannelOp {
AddChannel(Channel),
RemoveChannelByName(String),
}
impl ChannelOp {
fn to_scheme_sexp(&self) -> String {
match self {
ChannelOp::AddChannel(ch) => {
let ch_sexp = channel_to_sexp(ch);
format!("(add-channel {ch_sexp})")
}
ChannelOp::RemoveChannelByName(name) => {
format!("(remove-channel-by-name {})", scheme_symbol(name))
}
}
}
}
fn channel_to_sexp(ch: &Channel) -> String {
let mut s = String::from("(channel");
let _ = write!(s, " (name '{})", scheme_symbol(&ch.name));
let _ = write!(s, " (url {})", scheme_quote_string(&ch.url));
if let Some(b) = &ch.branch {
let _ = write!(s, " (branch {})", scheme_quote_string(b));
}
if let Some(c) = &ch.commit {
let _ = write!(s, " (commit {})", scheme_quote_string(c));
}
if let (Some(ic), Some(fpr)) = (&ch.introduction_commit, &ch.introduction_fingerprint) {
let _ = write!(
s,
" (introduction (make-channel-introduction {} (openpgp-fingerprint {})))",
scheme_quote_string(ic),
scheme_quote_string(fpr),
);
}
s.push(')');
s
}
pub(crate) fn is_valid_channel_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '+' | '.'))
}
fn scheme_symbol(name: &str) -> &str {
name
}
fn scheme_quote_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
_ => out.push(c),
}
}
out.push('"');
out
}
fn interpret_response(v: &lexpr::Value) -> Result<String, ChannelsError> {
let mut it = v.list_iter().ok_or_else(|| ChannelsError::ParseError {
message: format!("response is not a list: {v:?}"),
line: None,
column: None,
})?;
let head =
it.next()
.and_then(lexpr::Value::as_symbol)
.ok_or_else(|| ChannelsError::ParseError {
message: format!("response missing head: {v:?}"),
line: None,
column: None,
})?;
match head {
"ok" => {
let payload = it.next().ok_or_else(|| ChannelsError::ParseError {
message: "ok response missing payload".into(),
line: None,
column: None,
})?;
let s = payload.as_str().ok_or_else(|| ChannelsError::ParseError {
message: format!("ok payload not a string: {payload:?}"),
line: None,
column: None,
})?;
Ok(s.to_owned())
}
"error" => {
let kind = it
.next()
.and_then(lexpr::Value::as_symbol)
.unwrap_or("unknown")
.to_owned();
let msg = it
.next()
.and_then(lexpr::Value::as_str)
.unwrap_or("<no message>")
.to_owned();
let line = it.next().and_then(lexpr::Value::as_u64).map(|n| n as u32);
let column = it.next().and_then(lexpr::Value::as_u64).map(|n| n as u32);
Err(match kind.as_str() {
"parse-error" => ChannelsError::ParseError {
message: msg,
line,
column,
},
"duplicate-name" => ChannelsError::DuplicateName { name: msg },
"not-found" => ChannelsError::NotFound { name: msg },
"unsupported-op" => ChannelsError::UnsupportedOp { op: msg },
"guix-locked" => ChannelsError::EvalError {
message: format!("guix-locked: {msg}"),
line,
column,
},
"wrapper-around-target" => ChannelsError::EvalError {
message: format!("wrapper-around-target: {msg}"),
line,
column,
},
"eval-error" => ChannelsError::EvalError {
message: msg,
line,
column,
},
_ => ChannelsError::EvalError {
message: format!("{kind}: {msg}"),
line,
column,
},
})
}
other => Err(ChannelsError::ParseError {
message: format!("unexpected response head `{other}`: {v:?}"),
line: None,
column: None,
}),
}
}
fn default_path() -> Result<PathBuf, ChannelsError> {
let home = std::env::var_os("HOME")
.ok_or_else(|| ChannelsError::Internal("HOME not set; pass an explicit path".into()))?;
let mut p = PathBuf::from(home);
p.push(".config/guix/channels.scm");
Ok(p)
}
fn resolves_into_store(path: &Path) -> bool {
match std::fs::read_link(path) {
Ok(target) => {
let resolved = if target.is_absolute() {
target
} else {
path.parent()
.map_or(target.clone(), |parent| parent.join(&target))
};
let stringy = resolved.to_string_lossy().to_string();
stringy.starts_with("/gnu/store/")
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_string_escapes_backslash_and_quote() {
assert_eq!(scheme_quote_string("a\\b\"c"), r#""a\\b\"c""#);
}
#[test]
fn channel_to_sexp_includes_optional_fields() {
let ch = Channel {
name: "foo".into(),
url: "https://example/foo.git".into(),
branch: Some("master".into()),
commit: Some("deadbeef".into()),
introduction_commit: Some("intro-commit".into()),
introduction_fingerprint: Some("AA BB".into()),
};
let s = channel_to_sexp(&ch);
assert!(s.contains("(name 'foo)"));
assert!(s.contains("(url \"https://example/foo.git\")"));
assert!(s.contains("(branch \"master\")"));
assert!(s.contains("(commit \"deadbeef\")"));
assert!(s.contains("(introduction"));
assert!(s.contains("\"AA BB\""));
}
#[test]
fn channel_to_sexp_omits_missing_optionals() {
let ch = Channel {
name: "foo".into(),
url: "https://example/foo.git".into(),
branch: None,
commit: None,
introduction_commit: None,
introduction_fingerprint: None,
};
let s = channel_to_sexp(&ch);
assert!(!s.contains("branch"));
assert!(!s.contains("commit"));
assert!(!s.contains("introduction"));
}
#[test]
fn is_valid_channel_name_accepts_scheme_safe() {
assert!(is_valid_channel_name("good-name_1.2"));
assert!(is_valid_channel_name("guix"));
assert!(is_valid_channel_name("non+guix"));
}
#[test]
fn is_valid_channel_name_rejects_pathological() {
assert!(!is_valid_channel_name(""));
assert!(!is_valid_channel_name("bad name"));
assert!(!is_valid_channel_name("nope;(drop)"));
assert!(!is_valid_channel_name("with/slash"));
}
#[cfg(unix)]
#[test]
fn resolves_into_store_detects_dangling_store_link() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let link = dir.path().join("channels.scm");
symlink(
"/gnu/store/00000000000000000000000000000000-channels/channels.scm",
&link,
)
.expect("symlink");
assert!(
resolves_into_store(&link),
"expected store-shaped symlink target to trip is_store_managed"
);
}
#[test]
fn resolves_into_store_false_for_regular_file() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path().join("channels.scm");
std::fs::write(&p, "(list)").expect("write");
assert!(!resolves_into_store(&p));
}
#[test]
fn backup_path_is_always_scm_bak() {
let mk = |path: &str| ChannelsFile {
path: PathBuf::from(path),
list: crate::parsers::sexp::ChannelsList::Explicit(Vec::new()),
raw: String::new(),
is_store_managed: false,
};
assert_eq!(
mk("/tmp/channels.scm").backup_path(),
PathBuf::from("/tmp/channels.scm.bak"),
);
assert_eq!(
mk("/tmp/channels").backup_path(),
PathBuf::from("/tmp/channels.scm.bak"),
);
assert_eq!(
mk("/tmp/foo.txt").backup_path(),
PathBuf::from("/tmp/foo.scm.bak"),
);
}
}