use std::collections::HashMap;
use std::ffi::OsStr;
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use serde::Deserialize;
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageReference {
pub stream: String,
pub version: Option<String>,
}
impl FromStr for ImageReference {
type Err = CoreError;
fn from_str(s: &str) -> Result<Self> {
let (stream, version) = match s.split_once('@') {
Some((stream, version)) => (stream, Some(version.to_string())),
None => (s, None),
};
if stream.is_empty()
|| !stream
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
{
return Err(CoreError::image(format!("invalid image reference '{s}'")));
}
if let Some(v) = &version {
if v.is_empty() {
return Err(CoreError::image(format!("invalid image reference '{s}'")));
}
}
Ok(Self {
stream: stream.to_string(),
version,
})
}
}
impl fmt::Display for ImageReference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.version {
Some(v) => write!(f, "{}@{v}", self.stream),
None => write!(f, "{}", self.stream),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RemoteIndex {
pub schema_version: u32,
pub images: HashMap<String, RemoteStream>,
}
#[derive(Debug, Deserialize)]
pub struct RemoteStream {
pub latest: String,
pub versions: HashMap<String, RemoteVersion>,
}
#[derive(Debug, Deserialize)]
pub struct RemoteVersion {
pub manifest: String,
}
impl RemoteIndex {
pub fn resolve(&self, reference: &ImageReference) -> Result<(String, String)> {
let stream = self
.images
.get(&reference.stream)
.ok_or_else(|| CoreError::not_found(format!("image stream '{}'", reference.stream)))?;
let version = reference
.version
.clone()
.unwrap_or_else(|| stream.latest.clone());
let entry = stream.versions.get(&version).ok_or_else(|| {
CoreError::not_found(format!("image '{}@{version}'", reference.stream))
})?;
Ok((version, entry.manifest.clone()))
}
}
#[derive(Debug, Clone)]
pub enum RemoteLocation {
Http(reqwest::Url),
File(PathBuf),
}
impl RemoteLocation {
#[must_use]
pub fn parse(s: &str) -> Self {
if s.starts_with("http://") || s.starts_with("https://") {
match reqwest::Url::parse(s) {
Ok(url) => return Self::Http(url),
Err(_) => return Self::File(PathBuf::from(s)),
}
}
Self::File(PathBuf::from(s))
}
pub fn join(&self, relative: &str) -> Result<Self> {
match self {
Self::Http(url) => {
let joined = url
.join(relative)
.map_err(|e| CoreError::image(format!("join '{relative}' to {url}: {e}")))?;
Ok(Self::Http(joined))
}
Self::File(path) => {
let dir = path.parent().unwrap_or(path);
Ok(Self::File(dir.join(relative)))
}
}
}
#[must_use]
pub fn as_dir(&self) -> Self {
match self {
Self::Http(url) => {
let mut s = url.to_string();
if !s.ends_with('/') {
s.push('/');
}
Self::parse(&s)
}
Self::File(path) => Self::File(path.join("placeholder")),
}
}
pub async fn fetch_bytes(&self) -> Result<Vec<u8>> {
match self {
Self::Http(url) => {
let resp = reqwest::get(url.clone())
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| CoreError::image(format!("fetch {url}: {e}")))?;
let bytes = resp
.bytes()
.await
.map_err(|e| CoreError::image(format!("fetch {url}: {e}")))?;
Ok(bytes.to_vec())
}
Self::File(path) => Ok(std::fs::read(path)?),
}
}
pub async fn fetch_json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
let bytes = self.fetch_bytes().await?;
serde_json::from_slice(&bytes).map_err(|e| CoreError::image(format!("parse {self}: {e}")))
}
}
impl fmt::Display for RemoteLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Http(url) => write!(f, "{url}"),
Self::File(path) => write!(f, "{}", path.display()),
}
}
}
pub(crate) fn validate_name(name: &str) -> Result<()> {
let mut components = Path::new(name).components();
match (components.next(), components.next()) {
(Some(Component::Normal(only)), None)
if only == OsStr::new(name) && !name.contains('\0') =>
{
Ok(())
}
_ => Err(CoreError::image(format!("invalid name '{name}'"))),
}
}
pub(crate) struct StagingGuard {
path: Option<PathBuf>,
}
impl StagingGuard {
pub(crate) fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
pub(crate) fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for StagingGuard {
fn drop(&mut self) {
if let Some(path) = &self.path {
let _ = std::fs::remove_dir_all(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_parses_stream_and_version() {
let r: ImageReference = "tahoe-base".parse().unwrap();
assert_eq!(r.stream, "tahoe-base");
assert_eq!(r.version, None);
let r: ImageReference = "tahoe-base@2026.07.03".parse().unwrap();
assert_eq!(r.stream, "tahoe-base");
assert_eq!(r.version.as_deref(), Some("2026.07.03"));
assert_eq!(r.to_string(), "tahoe-base@2026.07.03");
}
#[test]
fn reference_rejects_garbage() {
assert!("".parse::<ImageReference>().is_err());
assert!("tahoe base".parse::<ImageReference>().is_err());
assert!("tahoe-base@".parse::<ImageReference>().is_err());
assert!("../evil".parse::<ImageReference>().is_err());
}
#[test]
fn index_resolves_latest_and_pinned() {
let index: RemoteIndex = serde_json::from_str(
r#"{
"schema_version": 1,
"images": {
"tahoe-base": {
"latest": "2026.07.03",
"versions": {
"2026.07.03": { "manifest": "tahoe-base/2026.07.03/manifest.json" },
"2026.06.01": { "manifest": "tahoe-base/2026.06.01/manifest.json" }
}
}
}
}"#,
)
.unwrap();
let latest = index.resolve(&"tahoe-base".parse().unwrap()).unwrap();
assert_eq!(latest.0, "2026.07.03");
assert_eq!(latest.1, "tahoe-base/2026.07.03/manifest.json");
let pinned = index
.resolve(&"tahoe-base@2026.06.01".parse().unwrap())
.unwrap();
assert_eq!(pinned.0, "2026.06.01");
assert!(index.resolve(&"nope".parse().unwrap()).is_err());
assert!(
index
.resolve(&"tahoe-base@1999.01.01".parse().unwrap())
.is_err()
);
}
#[test]
fn location_joins_relatively() {
let base = RemoteLocation::parse("https://images.arcbox.dev");
let index = base.as_dir().join("index.json").unwrap();
assert_eq!(index.to_string(), "https://images.arcbox.dev/index.json");
let manifest = base
.as_dir()
.join("tahoe-base/2026.07.03/manifest.json")
.unwrap();
let disk = manifest.join("disk.img.zst").unwrap();
assert_eq!(
disk.to_string(),
"https://images.arcbox.dev/tahoe-base/2026.07.03/disk.img.zst"
);
let manifest = RemoteLocation::parse("/tmp/out/manifest.json");
let disk = manifest.join("disk.img.zst").unwrap();
assert_eq!(disk.to_string(), "/tmp/out/disk.img.zst");
}
#[test]
fn validate_name_accepts_plain_and_dotted_components() {
for ok in [
"tahoe-base",
"tahoe-base@2026.07.03",
"ubuntu-noble-arm64",
".pull-x",
".create-ci-1",
] {
assert!(validate_name(ok).is_ok(), "should accept {ok:?}");
}
}
#[test]
fn validate_name_rejects_traversal_and_separators() {
for bad in ["", ".", "..", "a/b", "/abs", "a/../b", "a\0b", "sub/dir"] {
assert!(validate_name(bad).is_err(), "should reject {bad:?}");
}
}
}