use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
const KIND_ROOTFS: &str = "heyvm.rootfs.v1";
const SCHEMA_VERSION: u32 = 1;
const ROOTFS_FILENAME: &str = "rootfs.ext4";
const ANN_PRIMITIVE: &str = "heyvm.primitive";
const ANN_IMAGE: &str = "heyvm.image";
const ANN_NOMINAL_SIZE: &str = "heyvm.nominal_size";
const PRIMITIVE_EXT4_RAW: &str = "ext4_raw";
const KIND_DOCKERFILE: &str = "heyvm.dockerfile.v1";
const DOCKERFILE_ENTRY: &str = "Dockerfile";
const CONTEXT_ENTRY: &str = "context.tar.gz";
const ANN_SIZE_MB: &str = "heyvm.size_mb";
const ANN_SOURCE: &str = "dockerfile.source";
const MAX_DOCKERFILE_BYTES: u64 = 1 << 20;
const HASH_CHUNK: usize = 1 << 20;
pub struct RegistryClient {
agent: ureq::Agent,
base: String,
auth: Option<String>,
gateway: Option<String>,
}
pub fn gateway_base(server: &str, namespace: &str) -> String {
format!(
"{}/namespaces/{}/artifacts",
normalize_url(server),
escape(namespace)
)
}
impl RegistryClient {
pub fn new(url: &str, api_key: Option<&str>, insecure: bool, timeout: Duration) -> Result<Self> {
Self::build(
normalize_url(url),
api_key.map(|k| format!("Bearer {k}")),
None,
insecure,
timeout,
)
}
pub fn gateway(
server: &str,
namespace: &str,
auth_header: Option<String>,
insecure: bool,
timeout: Duration,
) -> Result<Self> {
Self::build(
gateway_base(server, namespace),
auth_header,
Some(namespace.to_string()),
insecure,
timeout,
)
}
fn build(
base: String,
auth: Option<String>,
gateway: Option<String>,
insecure: bool,
timeout: Duration,
) -> Result<Self> {
let mut tls = ureq::native_tls::TlsConnector::builder();
if insecure {
tls.danger_accept_invalid_certs(true);
tls.danger_accept_invalid_hostnames(true);
}
let connector = tls.build().context("building the TLS connector")?;
let agent = ureq::AgentBuilder::new()
.timeout_connect(timeout)
.user_agent(concat!("heyctl/", env!("CARGO_PKG_VERSION")))
.tls_connector(Arc::new(connector))
.build();
Ok(Self {
agent,
base,
auth,
gateway,
})
}
pub fn url(&self) -> &str {
&self.base
}
pub fn has_credentials(&self) -> bool {
self.auth.is_some()
}
pub fn gateway_namespace(&self) -> Option<&str> {
self.gateway.as_deref()
}
fn request(&self, method: &str, path: &str) -> ureq::Request {
let req = self.agent.request(method, &format!("{}{}", self.base, path));
match &self.auth {
Some(h) => req.set("Authorization", h),
None => req,
}
}
fn seg(&self, reference: &str) -> String {
match self.gateway {
Some(_) => escape_keeping(reference, b"/:"),
None => escape(reference),
}
}
fn unreachable(&self, t: impl std::fmt::Display) -> anyhow::Error {
match &self.gateway {
Some(_) => anyhow!(
"cannot reach app-lb's artifact gateway at {} ({t}) — check the context's \
server with `heyctl whoami`",
self.base
),
None => anyhow!("cannot reach the artifact store at {} ({t})", self.base),
}
}
fn raw(&self, req: ureq::Request) -> Result<(u16, String)> {
match req.call() {
Ok(resp) => {
let status = resp.status();
Ok((status, resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Status(code, resp)) => {
Ok((code, resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Transport(t)) if self.gateway.is_some() => Err(self.unreachable(t)),
Err(ureq::Error::Transport(t)) => Err(anyhow!(
"cannot reach the artifact store at {} ({t}) — is `art serve` running, and is \
the url right?",
self.base
)),
}
}
fn send(&self, method: &str, path: &str) -> Result<(u16, String)> {
let (code, body) = self.raw(self.request(method, path))?;
if code >= 400 {
return Err(self.api_error(code, &body));
}
Ok((code, body))
}
fn json(&self, method: &str, path: &str) -> Result<Value> {
let (_, text) = self.send(method, path)?;
if text.trim().is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&text)
.with_context(|| format!("the store's answer to {method} {path} was not JSON"))
}
fn api_error(&self, code: u16, body: &str) -> anyhow::Error {
let detail = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("error")?.as_str().map(str::to_string))
.unwrap_or_else(|| body.trim().chars().take(300).collect());
if let Some(ns) = &self.gateway {
return gateway_error(ns, code, &detail, self.auth.is_some());
}
match code {
401 if self.auth.is_some() => anyhow!(
"the store rejected this API key (HTTP 401) — check it against the store's \
ART_API_KEY, or re-run `heyctl artifact login`"
),
401 => anyhow!(
"this store requires an API key (HTTP 401) — run `heyctl artifact login`, \
or pass --api-key"
),
403 => anyhow!(
"the store refused the write (HTTP 403){} — a store started with \
ART_READ_ONLY rejects every mutating route",
if detail.is_empty() { String::new() } else { format!(": {detail}") }
),
_ if detail.is_empty() => anyhow!("the store returned HTTP {code}"),
_ => anyhow!("{detail} (HTTP {code})"),
}
}
pub fn healthz(&self) -> Result<()> {
let (code, _) = self.send("GET", "/healthz")?;
if code == 200 {
Ok(())
} else {
bail!("GET /healthz answered HTTP {code}; that does not look like an artifact store")
}
}
pub fn tags(&self) -> Result<Value> {
self.json("GET", "/tags")
}
pub fn gateway_info(&self) -> Result<Value> {
self.json("GET", "")
}
pub fn usage(&self) -> Result<Value> {
self.json("GET", "/usage")
}
pub fn manifest(&self, reference: &str) -> Result<Value> {
self.json("GET", &format!("/manifests/{}", self.seg(reference)))
}
pub fn blob_exists(&self, digest: &str) -> Result<Option<u64>> {
let req = self.request("HEAD", &format!("/blobs/{}", self.seg(digest)));
match req.call() {
Ok(resp) => Ok(Some(
resp.header("Content-Length")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
)),
Err(ureq::Error::Status(404, _)) => Ok(None),
Err(ureq::Error::Status(code, resp)) => {
Err(self.api_error(code, &resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Transport(t)) => Err(self.unreachable(t)),
}
}
pub fn put_blob(&self, digest: &str, path: &Path, size: u64) -> Result<bool> {
let file = std::fs::File::open(path)
.with_context(|| format!("opening {}", path.display()))?;
let req = self
.request("PUT", &format!("/blobs/{}", self.seg(digest)))
.set("Content-Type", "application/octet-stream")
.set("Content-Length", &size.to_string());
match req.send(file) {
Ok(resp) => Ok(resp.status() == 201),
Err(ureq::Error::Status(code, resp)) => {
Err(self.api_error(code, &resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Transport(t)) => Err(anyhow!(
"the upload to {} failed ({t}) — nothing was tagged, so no tag moved",
self.base
)),
}
}
pub fn put_manifest(&self, manifest: &Value) -> Result<String> {
let req = self
.request("PUT", "/manifests")
.set("Content-Type", "application/json");
let (code, body) = match req.send_json(manifest) {
Ok(resp) => (resp.status(), resp.into_string().unwrap_or_default()),
Err(ureq::Error::Status(code, resp)) => {
return Err(self.api_error(code, &resp.into_string().unwrap_or_default()));
}
Err(ureq::Error::Transport(t)) => return Err(self.unreachable(t)),
};
if code >= 400 {
return Err(self.api_error(code, &body));
}
let v: Value = serde_json::from_str(&body)
.context("the store's answer to PUT /manifests was not JSON")?;
v.get("digest")
.and_then(|d| d.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow!("the store stored the manifest but reported no digest"))
}
pub fn put_tag(&self, name: &str, digest: &str) -> Result<()> {
let req = self
.request("PUT", &format!("/tags/{}", self.seg(name)))
.set("Content-Type", "text/plain");
match req.send_string(digest) {
Ok(_) => Ok(()),
Err(ureq::Error::Status(code, resp)) => {
Err(self.api_error(code, &resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Transport(t)) => Err(self.unreachable(t)),
}
}
pub fn delete_tag(&self, name: &str) -> Result<()> {
self.send("DELETE", &format!("/tags/{}", self.seg(name))).map(|_| ())
}
pub fn put_repo(&self, repo: &str, public: bool, description: Option<&str>) -> Result<Value> {
let req = self
.request("PUT", &format!("/repos/{}", self.seg(repo)))
.set("Content-Type", "application/json");
match req.send_json(json!({ "public": public, "description": description })) {
Ok(resp) => serde_json::from_str(&resp.into_string().unwrap_or_default())
.context("the store's answer to PUT /repos was not JSON"),
Err(ureq::Error::Status(code, resp)) => {
Err(self.api_error(code, &resp.into_string().unwrap_or_default()))
}
Err(ureq::Error::Transport(t)) => Err(self.unreachable(t)),
}
}
pub fn resolve(&self, reference: &str) -> Result<Option<Resolved>> {
let (code, body) = self.raw(self.request("GET", &format!("/manifests/{}", self.seg(reference))))?;
match code {
200 => {
let m: Value = serde_json::from_str(&body)
.context("the store's manifest was not JSON")?;
return Ok(Some(Resolved::Manifest(m)));
}
404 => {}
_ => return Err(self.api_error(code, &body)),
}
let (code, body) = self.raw(self.request("GET", &format!("/tags/{}", self.seg(reference))))?;
match code {
200 => {
let v: Value = serde_json::from_str(&body).context("the store's tag was not JSON")?;
let d = v
.get("digest")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("the store's tag carried no digest"))?;
Ok(Some(Resolved::Blob(d.to_string())))
}
404 if is_digest(reference) => Ok(Some(Resolved::Blob(reference.to_string()))),
404 => Ok(None),
_ => Err(self.api_error(code, &body)),
}
}
pub fn get_blob(&self, digest: &str, dest: &Path, mut progress: impl FnMut(u64, u64)) -> Result<u64> {
let resp = match self.request("GET", &format!("/blobs/{}", self.seg(digest))).call() {
Ok(r) => r,
Err(ureq::Error::Status(code, resp)) => {
return Err(self.api_error(code, &resp.into_string().unwrap_or_default()));
}
Err(ureq::Error::Transport(t)) => return Err(self.unreachable(t)),
};
let total = resp.header("Content-Length").and_then(|v| v.parse().ok()).unwrap_or(0);
let tmp = dest.with_file_name(format!(
".{}.heyctl-pull",
dest.file_name().and_then(|n| n.to_str()).unwrap_or("blob")
));
let result = (|| -> Result<u64> {
let mut out = std::fs::File::create(&tmp)
.with_context(|| format!("creating {}", tmp.display()))?;
let mut reader = resp.into_reader();
let mut hasher = Sha256::new();
let mut buf = vec![0u8; HASH_CHUNK];
let mut n_total = 0u64;
loop {
let n = reader.read(&mut buf).context("reading the download")?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
std::io::Write::write_all(&mut out, &buf[..n])
.with_context(|| format!("writing {}", tmp.display()))?;
n_total += n as u64;
progress(n_total, total);
}
out.sync_all().ok();
let actual = hex(&hasher.finalize());
if actual != digest {
bail!("the store sent bytes that hash to {actual}, not {digest}; nothing was written");
}
std::fs::rename(&tmp, dest)
.with_context(|| format!("moving the download into {}", dest.display()))?;
Ok(n_total)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
}
fn gateway_error(ns: &str, code: u16, detail: &str, has_credentials: bool) -> anyhow::Error {
let detail_suffix = if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
};
match code {
401 if has_credentials => anyhow!(
"app-lb rejected this context's credential (HTTP 401) — check it with \
`heyctl whoami`, or log in again with `heyctl login`"
),
401 => anyhow!(
"app-lb requires a credential for artifacts (HTTP 401) — log in with \
`heyctl login`, or pass --token"
),
403 => anyhow!(
"app-lb refused (HTTP 403){detail_suffix} — artifacts are confined to namespace \
{ns:?} (tags under `{ns}/`), and writes need a credential with admin on it"
),
503 => anyhow!(
"this app-lb has no global artifact store configured (HTTP 503){detail_suffix} — \
ask its operator, or talk to a store directly with --registry-url"
),
502 => anyhow!(
"app-lb could not reach its artifact store (HTTP 502){detail_suffix} — try again, \
or ask the app-lb's operator"
),
_ if detail.is_empty() => anyhow!("app-lb's artifact gateway returned HTTP {code}"),
_ => anyhow!("{detail} (HTTP {code})"),
}
}
pub fn check_namespaced(reference: &str, ns: &str, allow_digest: bool) -> Result<()> {
if allow_digest && is_digest(reference) {
return Ok(());
}
let prefix = format!("{ns}/");
if reference.starts_with(&prefix) && reference.len() > prefix.len() {
return Ok(());
}
bail!(
"{reference:?} is outside namespace {ns:?}: through app-lb every tag starts with \
`{prefix}` — try `{prefix}{}`",
reference.trim_start_matches('/')
)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Resolved {
Manifest(Value),
Blob(String),
}
pub fn is_digest(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn is_safe_entry_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0')
}
pub fn rootfs_manifest(digest: &str, size: u64, image: &str) -> Value {
json!({
"schema": SCHEMA_VERSION,
"kind": KIND_ROOTFS,
"entries": [{ "name": ROOTFS_FILENAME, "digest": digest, "size": size }],
"annotations": {
ANN_IMAGE: image,
ANN_NOMINAL_SIZE: size.to_string(),
ANN_PRIMITIVE: PRIMITIVE_EXT4_RAW,
}
})
}
pub fn dockerfile_manifest(
dockerfile: (&str, u64),
context: Option<(&str, u64)>,
image_name: Option<&str>,
size_mb: Option<u64>,
source: Option<&str>,
) -> Value {
let mut entries = vec![json!({
"name": DOCKERFILE_ENTRY,
"digest": dockerfile.0,
"size": dockerfile.1,
})];
if let Some((digest, size)) = context {
entries.push(json!({ "name": CONTEXT_ENTRY, "digest": digest, "size": size }));
}
let mut annotations = serde_json::Map::new();
if let Some(n) = image_name {
annotations.insert(ANN_IMAGE.into(), Value::String(n.to_string()));
}
if let Some(mb) = size_mb {
annotations.insert(ANN_SIZE_MB.into(), Value::String(mb.to_string()));
}
if let Some(s) = source {
annotations.insert(ANN_SOURCE.into(), Value::String(s.to_string()));
}
json!({
"schema": SCHEMA_VERSION,
"kind": KIND_DOCKERFILE,
"entries": entries,
"annotations": annotations,
})
}
pub fn pack_context(dir: &Path, dest: &Path) -> Result<u64> {
if !dir.is_dir() {
bail!("build context {} is not a directory", dir.display());
}
let out = std::fs::File::create(dest)
.with_context(|| format!("creating {}", dest.display()))?;
let gz = flate2::write::GzEncoder::new(out, flate2::Compression::default());
let mut builder = tar::Builder::new(gz);
builder.mode(tar::HeaderMode::Deterministic);
builder
.append_dir_all(".", dir)
.with_context(|| format!("packing {}", dir.display()))?;
let gz = builder.into_inner().context("finishing the context archive")?;
let mut out = gz.finish().context("compressing the context archive")?;
out.flush().context("flushing the context archive")?;
out.sync_all().context("syncing the context archive")?;
Ok(std::fs::metadata(dest)
.with_context(|| format!("reading {}", dest.display()))?
.len())
}
pub fn check_dockerfile_size(path: &Path, size: u64) -> Result<()> {
if size > MAX_DOCKERFILE_BYTES {
bail!(
"{} is {}; a Dockerfile is expected to be under {}. Did you mean --context?",
path.display(),
crate::output::bytes(size),
crate::output::bytes(MAX_DOCKERFILE_BYTES),
);
}
Ok(())
}
pub fn hash_file(path: &Path, mut progress: impl FnMut(u64, u64)) -> Result<(String, u64)> {
let mut file =
std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let total = file.metadata().map(|m| m.len()).unwrap_or(0);
let mut hasher = Sha256::new();
let mut buf = vec![0u8; HASH_CHUNK];
let mut read_total: u64 = 0;
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("reading {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
read_total += n as u64;
progress(read_total, total);
}
Ok((hex(&hasher.finalize()), read_total))
}
pub fn heyvm_image_path(name: &str) -> Result<std::path::PathBuf> {
let dir = match std::env::var("MVM_DATA_DIR") {
Ok(d) if !d.trim().is_empty() => std::path::PathBuf::from(d),
_ => dirs::home_dir()
.context("cannot find a home directory to look for heyvm's images in")?
.join(".heyo"),
}
.join("images")
.join("firecracker");
let stem = name.strip_suffix(".ext4").unwrap_or(name);
let path = dir.join(format!("{stem}.ext4"));
if !path.exists() {
bail!(
"no heyvm image called {stem:?} — looked for {}. `heyvm mvm images` lists what \
is built, or give a path to an .ext4 file instead",
path.display()
);
}
Ok(path)
}
pub fn is_valid_tag(name: &str) -> bool {
if !(name.contains('/') || name.contains(':')) {
return is_flat_tag(name);
}
let (repo, tag) = name.split_once(':').unwrap_or((name, "latest"));
is_valid_repo(repo) && is_flat_tag(tag)
}
fn is_flat_tag(name: &str) -> bool {
if name.is_empty() || name.len() > 64 {
return false;
}
let first = name.as_bytes()[0];
if first == b'-' || first == b'.' {
return false;
}
name.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
}
pub fn is_valid_repo(repo: &str) -> bool {
if repo.is_empty() || repo.len() > 180 {
return false;
}
let segments: Vec<&str> = repo.split('/').collect();
segments.len() <= 4
&& segments.iter().all(|seg| {
let b = seg.as_bytes();
!b.is_empty()
&& b.len() <= 64
&& (b[0].is_ascii_lowercase() || b[0].is_ascii_digit())
&& b.iter().all(|&c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-')
})
})
}
pub fn repo_of(tag: &str) -> Option<&str> {
if !(tag.contains('/') || tag.contains(':')) {
return None;
}
Some(tag.split_once(':').map(|(r, _)| r).unwrap_or(tag))
}
pub fn split_store_ref(reference: &str) -> (Option<String>, String) {
if let Some((first, rest)) = reference.split_once('/')
&& (first.contains('.') || first.contains(':') || first == "localhost")
&& !rest.is_empty()
{
let scheme = if first.starts_with("localhost") || first.starts_with("127.") {
"http"
} else {
"https"
};
return (Some(format!("{scheme}://{first}")), rest.to_string());
}
(None, reference.to_string())
}
pub fn default_tag_for(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_str()?;
is_valid_tag(stem).then(|| stem.to_string())
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
fn normalize_url(s: &str) -> String {
let s = s.trim();
let with_scheme = if s.contains("://") {
s.to_string()
} else {
format!("http://{s}")
};
with_scheme.trim_end_matches('/').to_string()
}
fn escape(segment: &str) -> String {
escape_keeping(segment, b"")
}
fn escape_keeping(segment: &str, keep: &[u8]) -> String {
let mut out = String::with_capacity(segment.len());
for b in segment.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ if keep.contains(&b) => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_store_url_gets_a_scheme_and_loses_its_trailing_slash() {
assert_eq!(normalize_url("localhost:8080"), "http://localhost:8080");
assert_eq!(normalize_url("http://art:8080/"), "http://art:8080");
assert_eq!(normalize_url(" https://art.example.com "), "https://art.example.com");
}
#[test]
fn a_manifest_matches_what_art_heyvm_import_writes() {
let m = rootfs_manifest("c74abee2", 4096, "debian-hermes");
assert_eq!(m["kind"], KIND_ROOTFS);
assert_eq!(m["schema"], 1);
assert_eq!(m["entries"][0]["name"], ROOTFS_FILENAME);
assert_eq!(m["entries"][0]["digest"], "c74abee2");
assert_eq!(m["entries"][0]["size"], 4096);
assert_eq!(m["annotations"][ANN_IMAGE], "debian-hermes");
assert_eq!(m["annotations"][ANN_PRIMITIVE], PRIMITIVE_EXT4_RAW);
assert_eq!(m["annotations"][ANN_NOMINAL_SIZE], "4096");
}
#[test]
fn a_dockerfile_manifest_matches_what_art_dockerfile_put_writes() {
let m = dockerfile_manifest(
("a1b2", 812),
Some(("c3d4", 40213)),
Some("web"),
Some(4096),
None,
);
assert_eq!(m["kind"], KIND_DOCKERFILE);
assert_eq!(m["schema"], 1);
assert_eq!(m["entries"][0]["name"], DOCKERFILE_ENTRY);
assert_eq!(m["entries"][0]["digest"], "a1b2");
assert_eq!(m["entries"][1]["name"], CONTEXT_ENTRY);
assert_eq!(m["entries"][1]["size"], 40213);
assert_eq!(m["annotations"][ANN_IMAGE], "web");
assert_eq!(m["annotations"][ANN_SIZE_MB], "4096");
}
#[test]
fn an_unset_annotation_is_absent_rather_than_empty() {
let m = dockerfile_manifest(("a1b2", 812), None, None, None, None);
assert_eq!(m["annotations"].as_object().unwrap().len(), 0);
assert_eq!(m["entries"].as_array().unwrap().len(), 1);
assert!(m["annotations"].get(ANN_IMAGE).is_none());
}
#[test]
fn packing_the_same_tree_twice_gives_the_same_bytes() {
let dir = std::env::temp_dir().join(format!("heyctl-pack-{}", std::process::id()));
let ctx = dir.join("ctx");
std::fs::create_dir_all(ctx.join("app")).unwrap();
std::fs::write(ctx.join("app/main.rs"), b"fn main() {}").unwrap();
std::fs::write(ctx.join("README"), b"hi").unwrap();
let first = dir.join("1.tar.gz");
let second = dir.join("2.tar.gz");
assert!(pack_context(&ctx, &first).unwrap() > 0);
pack_context(&ctx, &second).unwrap();
assert_eq!(
std::fs::read(&first).unwrap(),
std::fs::read(&second).unwrap()
);
let gz = flate2::read::GzDecoder::new(std::fs::File::open(&first).unwrap());
let names: Vec<String> = tar::Archive::new(gz)
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().display().to_string())
.collect();
assert!(names.iter().any(|n| n.ends_with("app/main.rs")), "{names:?}");
assert!(names.iter().any(|n| n.ends_with("README")), "{names:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_context_passed_as_the_dockerfile_is_caught_before_the_upload() {
let p = Path::new("Dockerfile");
assert!(check_dockerfile_size(p, 4096).is_ok());
let e = check_dockerfile_size(p, MAX_DOCKERFILE_BYTES + 1).unwrap_err();
assert!(e.to_string().contains("--context"), "{e}");
}
#[test]
fn tags_follow_the_stores_rules() {
assert!(is_valid_tag("debian-hermes"));
assert!(is_valid_tag("ubuntu-24.04"));
assert!(is_valid_tag("web_v2"));
assert!(!is_valid_tag(""));
assert!(!is_valid_tag("-leading-dash"));
assert!(!is_valid_tag(".hidden"));
assert!(!is_valid_tag("a//b"));
assert!(!is_valid_tag("A/b"));
assert!(!is_valid_tag("a/../b"));
assert!(!is_valid_tag("a/b:c/d"));
assert!(!is_valid_tag("has space"));
assert!(is_valid_tag("heyo/postgres:16"));
assert!(is_valid_tag("heyo/postgres"));
assert!(is_valid_tag("a/b"));
assert_eq!(repo_of("heyo/postgres:16"), Some("heyo/postgres"));
assert_eq!(repo_of("heyo/postgres"), Some("heyo/postgres"));
assert_eq!(repo_of("debian"), None);
}
#[test]
fn a_pull_reference_names_its_store_like_an_image_names_its_registry() {
assert_eq!(
split_store_ref("hub.heyo.work/heyo/postgres:16"),
(Some("https://hub.heyo.work".into()), "heyo/postgres:16".into())
);
assert_eq!(
split_store_ref("localhost:8080/heyo/pg:1"),
(Some("http://localhost:8080".into()), "heyo/pg:1".into())
);
assert_eq!(split_store_ref("heyo/postgres:16"), (None, "heyo/postgres:16".into()));
assert_eq!(split_store_ref("postgres:16"), (None, "postgres:16".into()));
assert_eq!(split_store_ref("debian"), (None, "debian".into()));
}
#[test]
fn only_plain_entry_names_are_written() {
assert!(is_safe_entry_name("rootfs.ext4"));
assert!(is_safe_entry_name("context.tar.gz"));
for bad in ["", "../x", "a/b", ".bashrc", ".."] {
assert!(!is_safe_entry_name(bad), "{bad}");
}
}
#[test]
fn a_files_own_name_is_its_default_tag() {
assert_eq!(
default_tag_for(Path::new("/home/x/.heyo/images/firecracker/artifacts.ext4")),
Some("artifacts".to_string())
);
assert_eq!(default_tag_for(Path::new("/tmp/.hidden.ext4")), None);
}
#[test]
fn hashing_a_file_reports_the_digest_and_the_bytes_it_covered() {
let dir = std::env::temp_dir().join(format!("heyctl-art-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("blob");
std::fs::write(&path, b"hello").unwrap();
let mut last = (0, 0);
let (digest, size) = hash_file(&path, |done, total| last = (done, total)).unwrap();
assert_eq!(
digest,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
assert_eq!(size, 5);
assert_eq!(last, (5, 5));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_gateway_sits_under_the_contexts_server_cloud_prefix_included() {
assert_eq!(
gateway_base("http://127.0.0.1:9090/", "acme"),
"http://127.0.0.1:9090/namespaces/acme/artifacts"
);
assert_eq!(gateway_base("lb:9090", "acme"), "http://lb:9090/namespaces/acme/artifacts");
assert_eq!(
gateway_base("https://cloud.example.com/namespaces/acme/lb", "acme"),
"https://cloud.example.com/namespaces/acme/lb/namespaces/acme/artifacts"
);
let c = RegistryClient::gateway(
"https://cloud.example.com/namespaces/acme/lb",
"acme",
Some("Bearer heyo_api_x".into()),
false,
Duration::from_secs(5),
)
.unwrap();
assert_eq!(c.gateway_namespace(), Some("acme"));
assert!(c.has_credentials());
}
#[test]
fn a_bare_tag_is_refused_through_the_gateway_with_the_namespaced_spelling() {
assert!(check_namespaced("acme/web:v2", "acme", false).is_ok());
assert!(check_namespaced("acme/a/b", "acme", false).is_ok());
let e = check_namespaced("web:v2", "acme", false).unwrap_err().to_string();
assert!(e.contains("acme/web:v2"), "{e}");
assert!(check_namespaced("other/web:v2", "acme", false).is_err());
assert!(check_namespaced("acme/", "acme", false).is_err());
assert!(check_namespaced("acmeweb", "acme", false).is_err());
let d = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
assert!(check_namespaced(d, "acme", true).is_ok());
assert!(check_namespaced(d, "acme", false).is_err());
}
#[test]
fn gateway_errors_never_mention_the_stores_key() {
for (code, needle) in [
(401, "heyctl login"),
(403, "namespace \"acme\""),
(503, "no global artifact store configured"),
(502, "could not reach its artifact store"),
] {
let e = gateway_error("acme", code, "", true).to_string();
assert!(e.contains(needle), "{code}: {e}");
assert!(!e.contains("ART_API_KEY") && !e.contains("artifact login"), "{e}");
}
}
#[test]
fn path_segments_are_escaped() {
assert_eq!(escape("debian-hermes"), "debian-hermes");
assert_eq!(escape("a/b"), "a%2Fb");
assert_eq!(escape_keeping("acme/web:v2 x", b"/:"), "acme/web:v2%20x");
}
}