use std::path::{Path, PathBuf};
use bollard::auth::DockerCredentials;
use bollard::query_parameters::{
ImportImageOptions, PushImageOptions, SearchImagesOptions, TagImageOptions,
};
use futures_util::StreamExt;
use serde_json::Value;
use super::run::PullProgress;
use super::{DockerClient, describe};
const DOCKER_HUB: &str = "https://index.docker.io/v1/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResult {
pub name: String,
pub description: String,
pub stars: i64,
pub official: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageOp {
Search(String),
Tag {
image: String,
target: String,
},
Push(String),
Save(String),
Load(PathBuf),
}
pub fn registry_of(reference: &str) -> String {
match reference.split_once('/') {
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => {
first.to_owned()
}
_ => DOCKER_HUB.to_owned(),
}
}
fn split_reference(reference: &str) -> (&str, &str) {
match reference.rsplit_once(':') {
Some((repo, tag)) if !tag.contains('/') => (repo, tag),
_ => (reference, "latest"),
}
}
pub fn tar_name(reference: &str) -> String {
let (repo, tag) = split_reference(reference);
let name = repo.rsplit('/').next().unwrap_or(repo);
let clean = |s: &str| s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '.', "-");
if reference.starts_with("sha256:") {
format!("image-{}.tar", &reference[7..19.min(reference.len())])
} else {
format!("{}-{}.tar", clean(name), clean(tag))
}
}
fn without_emoji(text: &str) -> String {
let kept: String = text
.chars()
.filter(|c| {
!matches!(*c as u32,
0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0xFE00..=0xFE0F | 0x200D | 0x2B00..=0x2BFF)
})
.collect();
kept.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn config_path() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
return Some(PathBuf::from(dir).join("config.json"));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".docker").join("config.json"))
}
fn credentials_in(config: &Value, registry: &str) -> Option<DockerCredentials> {
let helper = config["credHelpers"][registry]
.as_str()
.or_else(|| config["credsStore"].as_str());
if let Some(helper) = helper
&& let Some(creds) = from_helper(helper, registry)
{
return Some(creds);
}
let auth = config["auths"][registry]["auth"].as_str()?;
let decoded = base64_decode(auth)?;
let (username, password) = decoded.split_once(':')?;
Some(DockerCredentials {
username: Some(username.to_owned()),
password: Some(password.to_owned()),
serveraddress: Some(registry.to_owned()),
..Default::default()
})
}
fn from_helper(helper: &str, registry: &str) -> Option<DockerCredentials> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new(format!("docker-credential-{helper}"))
.arg("get")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(registry.as_bytes()).ok()?;
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let value: Value = serde_json::from_slice(&output.stdout).ok()?;
let username = value["Username"].as_str()?.to_owned();
let secret = value["Secret"].as_str()?.to_owned();
Some(if username == "<token>" {
DockerCredentials {
identitytoken: Some(secret),
serveraddress: Some(registry.to_owned()),
..Default::default()
}
} else {
DockerCredentials {
username: Some(username),
password: Some(secret),
serveraddress: Some(registry.to_owned()),
..Default::default()
}
})
}
pub fn credentials_for(reference: &str) -> Option<DockerCredentials> {
let text = std::fs::read_to_string(config_path()?).ok()?;
let config: Value = serde_json::from_str(&text).ok()?;
credentials_in(&config, ®istry_of(reference))
}
fn base64_decode(input: &str) -> Option<String> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut bits = 0u32;
let mut count = 0;
let mut out = Vec::new();
for byte in input.trim_end_matches('=').bytes() {
let value = ALPHABET.iter().position(|b| *b == byte)? as u32;
bits = (bits << 6) | value;
count += 6;
if count >= 8 {
count -= 8;
out.push((bits >> count) as u8);
bits &= (1 << count) - 1;
}
}
String::from_utf8(out).ok()
}
impl DockerClient {
pub async fn search_images(&self, term: &str) -> Result<Vec<SearchResult>, String> {
let results = self
.docker
.search_images(SearchImagesOptions {
term: term.to_owned(),
limit: Some(25),
..Default::default()
})
.await
.map_err(describe)?;
let mut results: Vec<SearchResult> = results
.into_iter()
.map(|r| SearchResult {
name: r.name.unwrap_or_default(),
description: without_emoji(&r.description.unwrap_or_default()),
stars: r.star_count.unwrap_or_default(),
official: r.is_official.unwrap_or_default(),
})
.collect();
results.sort_by_key(|r| (!r.official, -r.stars));
Ok(results)
}
pub async fn tag_image(&self, image: &str, target: &str) -> Result<(), String> {
let (repo, tag) = split_reference(target.trim());
self.docker
.tag_image(
image,
Some(TagImageOptions {
repo: Some(repo.to_owned()),
tag: Some(tag.to_owned()),
}),
)
.await
.map_err(describe)
}
pub async fn push_image(
&self,
reference: &str,
mut progress: impl FnMut(PullProgress),
) -> Result<(), String> {
let (repo, tag) = split_reference(reference);
let mut stream = self.docker.push_image(
repo,
Some(PushImageOptions {
tag: Some(tag.to_owned()),
..Default::default()
}),
credentials_for(reference),
);
while let Some(item) = stream.next().await {
let info = item.map_err(describe)?;
if let Some(error) = info.error_detail.and_then(|e| e.message) {
return Err(error);
}
let detail = info.progress_detail.unwrap_or_default();
progress(PullProgress {
done: detail.current.unwrap_or_default().max(0) as u64,
total: detail.total.unwrap_or_default().max(0) as u64,
status: info.status.unwrap_or_default(),
pushing: true,
});
}
Ok(())
}
pub async fn save_image(&self, image: &str, dir: &Path) -> Result<PathBuf, String> {
let path = super::files::unique_path(dir, &tar_name(image));
let mut file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
let mut stream = self.docker.export_image(image);
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(chunk) => chunk,
Err(e) => {
let _ = std::fs::remove_file(&path);
return Err(describe(e));
}
};
std::io::Write::write_all(&mut file, &chunk).map_err(|e| e.to_string())?;
}
Ok(path)
}
pub async fn load_image(&self, path: &Path) -> Result<Vec<String>, String> {
let file = tokio::fs::File::open(path)
.await
.map_err(|e| format!("Couldn't open {}: {e}", path.display()))?;
let chunks = futures_util::stream::unfold(file, |mut file| async move {
use tokio::io::AsyncReadExt;
let mut buffer = vec![0; 1 << 20];
match file.read(&mut buffer).await {
Ok(0) => None,
Ok(n) => {
buffer.truncate(n);
Some((Ok::<_, std::io::Error>(bytes::Bytes::from(buffer)), file))
}
Err(e) => Some((Err(e), file)),
}
});
let mut stream =
self.docker
.import_image_stream(ImportImageOptions::default(), chunks, None);
let mut loaded = Vec::new();
while let Some(item) = stream.next().await {
let info = item.map_err(describe)?;
if let Some(error) = info.error_detail.and_then(|e| e.message) {
return Err(error);
}
if let Some(line) = info
.stream
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
{
loaded.push(line.to_owned());
}
}
Ok(loaded)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_registry() {
assert_eq!(registry_of("nginx:alpine"), DOCKER_HUB);
assert_eq!(registry_of("me/app"), DOCKER_HUB);
assert_eq!(registry_of("ghcr.io/me/app:1"), "ghcr.io");
assert_eq!(registry_of("localhost:5000/app"), "localhost:5000");
assert_eq!(registry_of("localhost/app"), "localhost");
}
#[test]
fn drops_emoji() {
assert_eq!(without_emoji("🐳 Docker ✨ images"), "Docker images");
}
#[test]
fn names_saved_images() {
assert_eq!(tar_name("nginx:alpine"), "nginx-alpine.tar");
assert_eq!(tar_name("ghcr.io/me/app"), "app-latest.tar");
assert_eq!(
tar_name("sha256:abcdef0123456789"),
"image-abcdef012345.tar"
);
}
#[test]
fn reads_logins_from_the_config() {
let config: Value =
serde_json::from_str(r#"{"auths": {"ghcr.io": {"auth": "bWU6c2VjcmV0"}}}"#).unwrap();
let creds = credentials_in(&config, "ghcr.io").unwrap();
assert_eq!(creds.username.as_deref(), Some("me"));
assert_eq!(creds.password.as_deref(), Some("secret"));
assert!(credentials_in(&config, DOCKER_HUB).is_none());
assert_eq!(base64_decode("aGk=").as_deref(), Some("hi"));
}
#[tokio::test]
#[ignore]
async fn live_daemon_tags_saves_and_loads() {
use crate::docker::{Action, Resource};
let client = DockerClient::connect().unwrap();
let Some(image) = client.list_images().await.unwrap().into_iter().next() else {
return;
};
let name = "ferrodock-test/saved:1";
client.tag_image(&image.id, name).await.unwrap();
let dir = std::env::temp_dir().join(format!("ferrodock-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let saved = client.save_image(name, &dir).await.unwrap();
assert_eq!(saved, dir.join("saved-1.tar"));
assert!(std::fs::metadata(&saved).unwrap().len() > 0);
client
.perform(Action::Remove(Resource::Images), name)
.await
.ok();
let loaded = client.load_image(&saved).await.unwrap();
let tags: Vec<String> = client
.list_images()
.await
.unwrap()
.into_iter()
.flat_map(|i| i.tags)
.collect();
let pushed = client
.push_image("localhost:1/ferrodock-test:1", |_| {})
.await;
std::fs::remove_dir_all(&dir).unwrap();
client
.docker
.remove_image(
name,
None::<bollard::query_parameters::RemoveImageOptions>,
None,
)
.await
.ok();
assert!(loaded.iter().any(|l| l.contains(name)), "{loaded:?}");
assert!(tags.iter().any(|t| t == name));
assert!(pushed.is_err(), "nothing listens on localhost:1");
}
}