use anyhow::{Context, Result, anyhow};
use std::{
collections::HashMap,
path::Path,
process::{Command, Stdio},
};
use crate::{
choice, flake,
lockfile::{
LockFile, LockNodeLocked, LockNodeLockedTarball, LockNodeOriginal, LockNodeOriginalTarball,
},
};
const BUCKET: &str = "https://nix-releases.s3.amazonaws.com/";
const RELEASES: &str = "https://releases.nixos.org/";
#[derive(Debug, PartialEq)]
pub(crate) struct Channel {
name: String,
prefix: String,
}
impl Channel {
pub(crate) fn url(&self) -> String {
format!("https://channels.nixos.org/{}/nixexprs.tar.zst", self.name)
}
}
pub(crate) fn channel_for_ref(r#ref: &str) -> Option<Channel> {
let prefix = if let Some(rest) = r#ref.strip_prefix("nixos-") {
let release = rest.strip_suffix("-small").unwrap_or(rest);
if release != "unstable" && !is_version(release) {
return None;
}
format!("nixos/{rest}/")
} else if let Some(rest) = r#ref.strip_prefix("nixpkgs-") {
if rest == "unstable" {
"nixpkgs/".to_string()
} else if is_version(rest.strip_suffix("-darwin")?) {
format!("nixpkgs/{rest}/")
} else {
return None;
}
} else {
return None;
};
Some(Channel {
name: r#ref.to_string(),
prefix,
})
}
fn is_version(version: &str) -> bool {
let Some((year, month)) = version.split_once('.') else {
return false;
};
year.len() == 2
&& month.len() == 2
&& year
.chars()
.chain(month.chars())
.all(|c| c.is_ascii_digit())
}
#[derive(Debug, PartialEq)]
pub(crate) struct Release {
name: String,
url: String,
}
#[derive(Default)]
struct Bucket {
directories: HashMap<String, Vec<String>>,
}
impl Bucket {
fn find_release(&mut self, channel: &Channel, rev: &str) -> Result<Option<Release>> {
let directories = match self.directories.get(&channel.prefix) {
Some(directories) => directories,
None => {
let directories = list(&channel.prefix, true)?;
self.directories
.entry(channel.prefix.clone())
.or_insert(directories)
}
};
let Some(directory) = select_release_directory(directories, rev) else {
return Ok(None);
};
let directory = directory.to_string();
let nixexprs = select_nixexprs(&list(&directory, false)?)
.with_context(|| format!("release {directory} has no nixexprs tarball"))?
.to_string();
Ok(Some(Release {
name: directory
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap()
.to_string(),
url: format!("{RELEASES}{nixexprs}"),
}))
}
}
fn short_rev(rev: &str) -> &str {
&rev[..12]
}
fn select_release_directory<'a>(directories: &'a [String], rev: &str) -> Option<&'a str> {
let suffix = format!(".{}/", short_rev(rev));
directories
.iter()
.find(|directory| directory.ends_with(&suffix))
.map(String::as_str)
}
fn select_nixexprs(keys: &[String]) -> Option<&str> {
for extension in ["zst", "xz"] {
let suffix = format!("/nixexprs.tar.{extension}");
if let Some(key) = keys.iter().find(|key| key.ends_with(&suffix)) {
return Some(key);
}
}
None
}
fn list(prefix: &str, directories_only: bool) -> Result<Vec<String>> {
let mut entries = vec![];
let mut continuation: Option<String> = None;
loop {
let mut request = ureq::get(BUCKET)
.query("list-type", "2")
.query("prefix", prefix)
.query("max-keys", "1000");
if directories_only {
request = request.query("delimiter", "/");
}
if let Some(continuation) = &continuation {
request = request.query("continuation-token", continuation);
}
let body = request
.call()
.with_context(|| format!("failed to list {prefix} in releases bucket"))?
.body_mut()
.read_to_string()?;
let (mut page, next) = parse_listing(&body, directories_only)?;
entries.append(&mut page);
continuation = next;
if continuation.is_none() {
return Ok(entries);
}
}
}
fn parse_listing(body: &str, directories_only: bool) -> Result<(Vec<String>, Option<String>)> {
let document = roxmltree::Document::parse(body)?;
let root = document.root_element();
let (container, name) = if directories_only {
("CommonPrefixes", "Prefix")
} else {
("Contents", "Key")
};
let entries = root
.children()
.filter(|node| node.has_tag_name(container))
.map(|node| {
node.children()
.find(|child| child.has_tag_name(name))
.and_then(|child| child.text())
.map(str::to_string)
.with_context(|| format!("{container} without {name}"))
})
.collect::<Result<Vec<_>>>()?;
let continuation = root
.children()
.find(|node| node.has_tag_name("NextContinuationToken"))
.and_then(|node| node.text())
.map(str::to_string);
Ok((entries, continuation))
}
fn nar_hash(url: &str) -> Result<String> {
let output = Command::new("nix")
.args(["flake", "prefetch", "--json", url])
.stderr(Stdio::inherit())
.output()
.context("failed to run nix flake prefetch")?;
if !output.status.success() {
return Err(anyhow!("nix flake prefetch {url} failed"));
}
let prefetched: serde_json::Value = serde_json::from_slice(&output.stdout)?;
prefetched["hash"]
.as_str()
.map(str::to_string)
.context("nix flake prefetch gave no hash")
}
pub(crate) fn convert(root: &Path) -> Result<()> {
let root_display = root.to_string_lossy();
let mut lockfile = LockFile::from_root(root)?;
let mut bucket = Bucket::default();
println!("Flake: {root_display}");
println!("-------{}", "-".repeat(root_display.len()));
for input in lockfile.get_inputs() {
let Some(node) = lockfile.get_node_via_root(&input).cloned() else {
continue;
};
let (Some(LockNodeOriginal::GitHub(original)), Some(LockNodeLocked::GitHub(locked))) =
(&node.original, &node.locked)
else {
continue;
};
if !original.owner.eq_ignore_ascii_case("nixos")
|| !original.repo.eq_ignore_ascii_case("nixpkgs")
{
continue;
}
if original.rev.is_some() {
println!("[!] input {input} pins a rev; skipping");
continue;
}
let Some(r#ref) = &original.r#ref else {
println!("[!] input {input} tracks the default branch; skipping");
continue;
};
let Some(channel) = channel_for_ref(r#ref) else {
println!("[!] input {input} tracks {ref}, which isn't a channel; skipping");
continue;
};
let Some(release) = bucket.find_release(&channel, &locked.rev)? else {
println!(
"[!] input {input} is locked to {}, which isn't a {ref} release; skipping",
locked.rev
);
continue;
};
let existing = format!("{:?}", node.original.as_ref().unwrap());
let channel_url = channel.url();
println!();
println!("input {input} can use the {ref} channel tarball");
println!("in flake.nix: {existing} -> {channel_url}");
println!("release: {} (rev unchanged)", release.name);
println!("locked: {}", release.url);
match choice("(C)onvert to tarball, or (I)gnore?", &['c', 'i'])? {
'c' => {}
'i' => continue,
_ => unreachable!(),
}
let nar_hash = nar_hash(&release.url)?;
let mut converted = node.clone();
converted.locked = Some(LockNodeLocked::Tarball(LockNodeLockedTarball {
last_modified: locked.last_modified,
nar_hash,
rev: Some(locked.rev.clone()),
rev_count: None,
url: release.url,
unknown: Default::default(),
}));
converted.original = Some(LockNodeOriginal::Tarball(LockNodeOriginalTarball {
url: channel_url.clone(),
unknown: Default::default(),
}));
flake::replace_input_in_file(root, &input, &existing, &channel_url)?;
lockfile = lockfile.update_node_via_root(&input, &converted);
lockfile.write()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channels_map_to_bucket_prefixes() {
for (r#ref, prefix) in [
("nixos-unstable", "nixos/unstable/"),
("nixos-unstable-small", "nixos/unstable-small/"),
("nixos-26.05", "nixos/26.05/"),
("nixos-25.11-small", "nixos/25.11-small/"),
("nixpkgs-unstable", "nixpkgs/"),
("nixpkgs-26.05-darwin", "nixpkgs/26.05-darwin/"),
] {
let channel = channel_for_ref(r#ref).unwrap();
assert_eq!(prefix, channel.prefix);
assert_eq!(
format!("https://channels.nixos.org/{ref}/nixexprs.tar.zst"),
channel.url()
);
}
}
#[test]
fn non_channel_refs_are_rejected() {
for r#ref in [
"master",
"release-25.11",
"nixos-unstable-tiny",
"nixos-2026.05",
"nixpkgs-26.05",
"nixpkgs-staging",
] {
assert_eq!(None, channel_for_ref(r#ref));
}
}
const DIRECTORIES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>nix-releases</Name><Prefix>nixos/unstable/</Prefix><NextContinuationToken>1BEBEXw7nIQ==</NextContinuationToken><KeyCount>2</KeyCount><MaxKeys>2</MaxKeys><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><CommonPrefixes><Prefix>nixos/unstable/nixos-26.05pre924538.3497aa5c9457/</Prefix></CommonPrefixes><CommonPrefixes><Prefix>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/</Prefix></CommonPrefixes></ListBucketResult>"#;
const KEYS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>nix-releases</Name><Prefix>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/</Prefix><KeyCount>3</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/git-revision</Key><Size>40</Size></Contents><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.xz</Key><Size>1</Size></Contents><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.zst</Key><Size>1</Size></Contents></ListBucketResult>"#;
#[test]
fn directory_listings_yield_common_prefixes_and_continuation() {
let (directories, continuation) = parse_listing(DIRECTORIES, true).unwrap();
assert_eq!(
vec![
"nixos/unstable/nixos-26.05pre924538.3497aa5c9457/",
"nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/"
],
directories
);
assert_eq!(Some("1BEBEXw7nIQ==".to_string()), continuation);
}
#[test]
fn key_listings_yield_keys_and_no_continuation() {
let (keys, continuation) = parse_listing(KEYS, false).unwrap();
assert_eq!(3, keys.len());
assert_eq!(None, continuation);
assert_eq!(
Some("nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.zst"),
select_nixexprs(&keys)
);
}
#[test]
fn releases_without_zstd_fall_back_to_xz() {
let keys = vec![
"nixos/unstable/nixos-26.05pre924538.3497aa5c9457/git-revision".to_string(),
"nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz".to_string(),
];
assert_eq!(
Some("nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"),
select_nixexprs(&keys)
);
}
#[test]
fn releases_are_selected_by_short_rev() {
let (directories, _) = parse_listing(DIRECTORIES, true).unwrap();
assert_eq!(
Some("nixos/unstable/nixos-26.05pre924538.3497aa5c9457/"),
select_release_directory(&directories, "3497aa5c9457a9d88d71fa93a4a8368816fbeeba")
);
assert_eq!(
None,
select_release_directory(&directories, "0000000000000000000000000000000000000000")
);
}
}