use std::path::PathBuf;
use std::sync::Arc;
use eyre::{Context, Result, bail};
use indexmap::{IndexMap, IndexSet};
use sha2::{Digest, Sha256};
use crate::backend::backend_type::BackendType;
use crate::config::{Config, Settings};
use crate::file;
use crate::oci::layer::{self, LayerBlob, LayerOwner, PythonRelocation};
use crate::oci::layout::ImageLayout;
use crate::oci::manifest::{self, Descriptor, ImageConfig, ImageManifest, Platform, RootFs};
use crate::oci::packages;
use crate::oci::registry;
use crate::oci::{OciConfig, OciCopy};
use crate::system::ManagerPackages;
use crate::system::files::{FileMode, FileRequest};
use crate::toolset::{ToolVersion, Toolset};
pub(crate) const ANNOTATION_TOOL_SHORT: &str = "dev.mise.tool.short";
pub(crate) const ANNOTATION_TOOL_VERSION: &str = "dev.mise.tool.version";
pub(crate) const ANNOTATION_LAYER_PREFIX: &str = "dev.mise.layer.prefix";
pub(crate) const ANNOTATION_LAYER_OWNER: &str = "dev.mise.layer.owner";
pub(crate) const ANNOTATION_LAYER_RELOCATION: &str = "dev.mise.layer.relocation";
const TOOL_LAYER_RELOCATION_VERSION: &str = "2";
#[derive(Debug, Clone)]
pub(crate) struct BuildOptions {
pub out_dir: PathBuf,
pub from: Option<String>,
pub tag: Option<String>,
pub mount_point: Option<String>,
pub owner: Option<LayerOwner>,
pub include_mise: bool,
pub copy: Vec<OciCopy>,
pub reuse_from: Option<registry::RemoteImage>,
}
#[derive(Debug, PartialEq, Eq, Hash)]
struct ReuseKey {
short: String,
version: String,
prefix: String,
owner: String,
relocation: String,
}
#[derive(Debug, Clone)]
struct ReusedLayer {
media_type: String,
digest: String,
size: u64,
diff_id: String,
}
fn build_reuse_index(remote: ®istry::RemoteImage) -> IndexMap<ReuseKey, ReusedLayer> {
let mut index = IndexMap::new();
for (layer, diff_id) in remote.manifest.layers.iter().zip(&remote.diff_ids) {
let a = &layer.annotations;
let (Some(short), Some(version), Some(prefix), Some(owner), Some(relocation)) = (
a.get(ANNOTATION_TOOL_SHORT),
a.get(ANNOTATION_TOOL_VERSION),
a.get(ANNOTATION_LAYER_PREFIX),
a.get(ANNOTATION_LAYER_OWNER),
a.get(ANNOTATION_LAYER_RELOCATION),
) else {
continue;
};
index.insert(
ReuseKey {
short: short.clone(),
version: version.clone(),
prefix: prefix.clone(),
owner: owner.clone(),
relocation: relocation.clone(),
},
ReusedLayer {
media_type: layer.media_type.clone(),
digest: layer.digest.clone(),
size: layer.size,
diff_id: diff_id.clone(),
},
);
}
index
}
pub(crate) struct Builder {
pub cfg: Arc<Config>,
pub ts: Toolset,
pub oci: OciConfig,
pub opts: BuildOptions,
pub dotfiles: Vec<FileRequest>,
pub system_packages: Vec<ManagerPackages>,
}
pub(crate) struct BuildOutput {
pub out_dir: PathBuf,
pub manifest_digest: String,
pub tool_layers: Vec<ToolLayerInfo>,
}
pub(crate) struct ToolLayerInfo {
pub short: String,
pub version: String,
pub digest: String,
pub size: u64,
pub reused: bool,
}
impl Builder {
pub(crate) fn new(cfg: Arc<Config>, ts: Toolset, oci: OciConfig, opts: BuildOptions) -> Self {
Self {
cfg,
ts,
oci,
opts,
dotfiles: vec![],
system_packages: vec![],
}
}
pub(crate) fn with_dotfiles(mut self, dotfiles: Vec<FileRequest>) -> Self {
self.dotfiles = dotfiles;
self
}
pub(crate) fn with_system_packages(mut self, system_packages: Vec<ManagerPackages>) -> Self {
self.system_packages = system_packages;
self
}
pub(crate) async fn build(self) -> Result<BuildOutput> {
let versions = self.ts.list_current_versions();
if versions.is_empty() {
warn!("mise oci build: no tools in the toolset — image will have only the base layer");
}
reject_unsupported_backends(&versions)?;
file::create_dir_all(&self.opts.out_dir)?;
let layout = ImageLayout::init(&self.opts.out_dir)?;
let mount_point = self
.opts
.mount_point
.clone()
.or_else(|| self.oci.mount_point.clone())
.unwrap_or_else(|| Settings::get().oci.default_mount_point.clone());
let mount_point = mount_point.trim_end_matches('/').to_string();
if mount_point.is_empty() {
bail!("oci mount_point must not be empty");
}
if !mount_point.starts_with('/') {
bail!(
"oci mount_point must be an absolute path (got {mount_point:?}); \
a relative value makes MISE_DATA_DIR inside the container \
depend on the working directory and mis-resolve tools."
);
}
let owner = resolve_layer_owner(self.opts.owner, &self.oci);
let copies: Vec<&OciCopy> = self.oci.copy.iter().chain(&self.opts.copy).collect();
let from_ref = self
.opts
.from
.clone()
.or_else(|| self.oci.from.clone())
.or_else(|| {
let s = Settings::get().oci.default_from.clone();
if s.is_empty() { None } else { Some(s) }
})
.filter(|r| !r.is_empty() && r != "scratch");
let mut base_layers: Vec<Descriptor> = Vec::new();
let mut base_diff_ids: Vec<String> = Vec::new();
let mut base_config_json: Option<serde_json::Value> = None;
let mut platform: Option<Platform> = None;
if let Some(ref_) = &from_ref {
info!("pulling base image: {ref_}");
let desired = Some((
crate::oci::normalize_arch(std::env::consts::ARCH),
crate::oci::normalize_os(std::env::consts::OS),
));
let pull = registry::pull_base_image(ref_, &layout, desired)
.await
.wrap_err_with(|| format!("pulling base image {ref_}"))?;
base_layers = pull
.layers
.iter()
.map(|l| Descriptor {
media_type: manifest::media_type_to_oci(&l.media_type).to_string(),
size: l.size,
digest: l.digest.clone(),
annotations: l.annotations.clone(),
platform: l.platform.clone(),
})
.collect();
let diff_ids_raw = pull
.config_json
.get("rootfs")
.and_then(|r| r.get("diff_ids"))
.and_then(|d| d.as_array())
.ok_or_else(|| {
eyre::eyre!(
"pulled base image {ref_} has no rootfs.diff_ids in its config \
— cannot produce a valid OCI image on top of it"
)
})?;
base_diff_ids = diff_ids_raw
.iter()
.map(|v| {
v.as_str().map(String::from).ok_or_else(|| {
eyre::eyre!("base image {ref_} has a non-string entry in rootfs.diff_ids")
})
})
.collect::<Result<Vec<_>>>()?;
if base_diff_ids.len() != base_layers.len() {
bail!(
"base image {ref_} has {} layers in its manifest but {} diff_ids in its \
config — refusing to emit an OCI-spec-violating image",
base_layers.len(),
base_diff_ids.len()
);
}
platform = pull.platform;
base_config_json = Some(pull.config_json);
}
let owner_str = format!("{}:{}", owner.uid, owner.gid);
let python_relocations: Vec<PythonRelocation> = versions
.iter()
.filter(|(backend, _)| is_python_backend(backend.as_ref()))
.map(|(_, tv)| PythonRelocation {
version: tv.version.clone(),
host: tv.install_path(),
image: PathBuf::from(tool_in_image_path(&mount_point, tv)),
})
.collect();
let reuse_index = self
.opts
.reuse_from
.as_ref()
.map(build_reuse_index)
.unwrap_or_default();
let tool_reuse: Vec<Option<ReusedLayer>> = versions
.iter()
.map(|(_, tv)| {
reuse_index
.get(&ReuseKey {
short: tv.ba().short.clone(),
version: tv.version.clone(),
prefix: tool_tar_prefix(&mount_point, tv),
owner: owner_str.clone(),
relocation: tool_layer_relocation_key(tv, &python_relocations),
})
.cloned()
})
.collect();
let built_tool_count = tool_reuse.iter().filter(|r| r.is_none()).count();
if built_tool_count > 0 && std::env::consts::OS != "linux" {
warn!(
"building on {host} host — {n} tool layer(s) contain {host} binaries that \
will fail with `Exec format error` inside a linux container. Run \
`mise oci build` on a linux host (or in a linux container) for a working image.",
host = std::env::consts::OS,
n = built_tool_count
);
}
for (i, (_, tv)) in versions.iter().enumerate() {
if tool_reuse[i].is_some() {
continue; }
let install_path = tv.install_path();
if !install_path.is_dir() {
bail!(
"{} install path does not exist: {}. Run `mise install` first.",
tv.style(),
install_path.display()
);
}
}
let system_packages_layer = packages::build_system_packages_layer(
&layout,
&base_layers,
&self.system_packages,
platform
.as_ref()
.map(|p| p.architecture.as_str())
.unwrap_or_else(|| crate::oci::normalize_arch(std::env::consts::ARCH)),
)?;
struct ToolLayerEntry {
short: String,
version: String,
prefix: String,
relocation: String,
layer: ToolLayer,
}
enum ToolLayer {
Built(LayerBlob),
Reused(ReusedLayer),
}
let mut tool_layers: Vec<ToolLayerEntry> = Vec::new();
for (i, (_, tv)) in versions.iter().enumerate() {
let tv_prefix = tool_tar_prefix(&mount_point, tv);
let relocation_key = tool_layer_relocation_key(tv, &python_relocations);
let layer = if let Some(reused) = &tool_reuse[i] {
info!(
"oci: reusing {} layer from the cache image (unchanged)",
tv.style()
);
ToolLayer::Reused(reused.clone())
} else {
let is_pipx = tv.ba().backend_type() == BackendType::Pipx;
let mut paths = vec![(
tv.install_path(),
PathBuf::from(tool_in_image_path(&mount_point, tv)),
)];
if is_pipx {
paths.extend(
python_relocations
.iter()
.map(|python| (python.host.clone(), python.image.clone())),
);
}
let pythons = if is_pipx {
python_relocations.clone()
} else {
Vec::new()
};
let relocation = layer::ToolRelocation::new(paths).with_pythons(pythons);
let blob = layer::build_relocated_tool_layer_from_dir(
&tv.install_path(),
&tv_prefix,
owner,
&relocation,
)
.wrap_err_with(|| format!("building layer for {}", tv.style()))?;
ToolLayer::Built(blob)
};
tool_layers.push(ToolLayerEntry {
short: tv.ba().short.clone(),
version: tv.version.clone(),
prefix: tv_prefix,
relocation: relocation_key,
layer,
});
}
let mut copy_layers: Vec<(&OciCopy, LayerBlob)> = Vec::new();
for copy in copies {
copy.validate().map_err(eyre::Report::msg)?;
warn!(
"mise oci build: copying host path {} into the image at {} — review its \
contents for secrets or credentials before sharing the image",
copy.host.display(),
copy.image
);
let blob = layer::build_layer_from_path(©.host, ©.image, owner).wrap_err_with(
|| {
format!(
"copying host path {} to {}",
copy.host.display(),
copy.image
)
},
)?;
copy_layers.push((copy, blob));
}
let mut mise_layer: Option<LayerBlob> = None;
if self.opts.include_mise {
if std::env::consts::OS != "linux" {
warn!(
"embedding a {} mise binary in a linux OCI image — it will fail at runtime. \
Run `mise oci build` on linux, or pass --no-mise to skip embedding.",
std::env::consts::OS
);
}
match std::env::current_exe() {
Ok(exe) => {
let bytes = std::fs::read(&exe)
.wrap_err_with(|| format!("reading mise binary at {}", exe.display()))?;
let files = vec![("usr/local/bin/mise".to_string(), bytes, 0o755u32)];
mise_layer = Some(layer::build_layer_from_files(&files, owner)?);
}
Err(e) => {
warn!("could not locate mise binary to embed in image: {e}");
}
}
}
let dotfiles_layer = if self.dotfiles.is_empty() {
None
} else {
Some(build_dotfiles_layer(&self.cfg, &self.dotfiles, owner)?)
};
let config_layer = {
let config_toml = synthesize_embedded_config_toml(&versions, &mount_point);
let files = vec![(
"etc/mise/config.toml".to_string(),
config_toml.into_bytes(),
0o644u32,
)];
layer::build_layer_from_files(&files, owner)?
};
let mut tool_layer_infos = Vec::new();
let mut manifest_layers: Vec<Descriptor> = base_layers.clone();
let mut all_diff_ids: Vec<String> = base_diff_ids.clone();
if let Some(m) = &mise_layer {
layout.write_blob_with_digest(&m.digest, &m.bytes)?;
manifest_layers.push(Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: m.size,
digest: m.digest.clone(),
annotations: Default::default(),
platform: None,
});
all_diff_ids.push(m.diff_id.clone());
}
if let Some(blob) = &system_packages_layer {
layout.write_blob_with_digest(&blob.blob.digest, &blob.blob.bytes)?;
let mut annotations = IndexMap::new();
annotations.insert(
"dev.mise.system.packages".to_string(),
blob.manager.to_string(),
);
manifest_layers.push(Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: blob.blob.size,
digest: blob.blob.digest.clone(),
annotations,
platform: None,
});
all_diff_ids.push(blob.blob.diff_id.clone());
}
for entry in &tool_layers {
let mut annotations = IndexMap::new();
annotations.insert(ANNOTATION_TOOL_SHORT.to_string(), entry.short.clone());
annotations.insert(ANNOTATION_TOOL_VERSION.to_string(), entry.version.clone());
annotations.insert(ANNOTATION_LAYER_PREFIX.to_string(), entry.prefix.clone());
annotations.insert(ANNOTATION_LAYER_OWNER.to_string(), owner_str.clone());
annotations.insert(
ANNOTATION_LAYER_RELOCATION.to_string(),
entry.relocation.clone(),
);
let (media_type, digest, size, diff_id, reused) = match &entry.layer {
ToolLayer::Built(blob) => {
layout.write_blob_with_digest(&blob.digest, &blob.bytes)?;
(
manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
blob.digest.clone(),
blob.size,
blob.diff_id.clone(),
false,
)
}
ToolLayer::Reused(r) => (
r.media_type.clone(),
r.digest.clone(),
r.size,
r.diff_id.clone(),
true,
),
};
manifest_layers.push(Descriptor {
media_type,
size,
digest: digest.clone(),
annotations,
platform: None,
});
all_diff_ids.push(diff_id);
tool_layer_infos.push(ToolLayerInfo {
short: entry.short.clone(),
version: entry.version.clone(),
digest,
size,
reused,
});
}
for (copy, blob) in ©_layers {
layout.write_blob_with_digest(&blob.digest, &blob.bytes)?;
let mut annotations = IndexMap::new();
annotations.insert("dev.mise.copy".to_string(), copy.image.clone());
manifest_layers.push(Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: blob.size,
digest: blob.digest.clone(),
annotations,
platform: None,
});
all_diff_ids.push(blob.diff_id.clone());
}
if let Some(blob) = &dotfiles_layer {
layout.write_blob_with_digest(&blob.digest, &blob.bytes)?;
let mut annotations = IndexMap::new();
annotations.insert("dev.mise.dotfiles".to_string(), "true".to_string());
manifest_layers.push(Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: blob.size,
digest: blob.digest.clone(),
annotations,
platform: None,
});
all_diff_ids.push(blob.diff_id.clone());
}
{
layout.write_blob_with_digest(&config_layer.digest, &config_layer.bytes)?;
manifest_layers.push(Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: config_layer.size,
digest: config_layer.digest.clone(),
annotations: Default::default(),
platform: None,
});
all_diff_ids.push(config_layer.diff_id.clone());
}
let image_config = self
.build_image_config(
&versions,
&tool_reuse,
&mount_point,
base_config_json.as_ref(),
all_diff_ids.clone(),
&platform,
)
.await?;
let config_bytes = serde_json::to_vec(&image_config)?;
let (config_digest, config_size) = layout.write_blob(&config_bytes)?;
let config_descriptor = Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_CONFIG.to_string(),
size: config_size,
digest: config_digest.clone(),
annotations: Default::default(),
platform: None,
};
let mut manifest_annotations: IndexMap<String, String> = Default::default();
if let Some(ref_) = &from_ref {
manifest_annotations.insert(
crate::oci::registry::ANNOTATION_BASE_NAME.to_string(),
ref_.clone(),
);
}
let image_manifest = ImageManifest {
schema_version: 2,
media_type: manifest::MEDIA_TYPE_OCI_MANIFEST.to_string(),
config: config_descriptor,
layers: manifest_layers,
annotations: manifest_annotations,
};
let (manifest_digest, manifest_size) = layout.write_manifest(&image_manifest)?;
let tag = self.opts.tag.clone().or_else(|| self.oci.tag.clone());
layout.write_index(&manifest_digest, manifest_size, platform, tag.as_deref())?;
Ok(BuildOutput {
out_dir: self.opts.out_dir.clone(),
manifest_digest,
tool_layers: tool_layer_infos,
})
}
async fn build_image_config(
&self,
versions: &[(Arc<dyn crate::backend::Backend>, ToolVersion)],
tool_reuse: &[Option<ReusedLayer>],
mount_point: &str,
base_config_json: Option<&serde_json::Value>,
diff_ids: Vec<String>,
platform: &Option<Platform>,
) -> Result<ImageConfig> {
use crate::oci::manifest::Config as ImgConfig;
let mut env_pairs: IndexMap<String, String> = IndexMap::new();
let mut cmd: Option<Vec<String>> = None;
let mut entrypoint: Option<Vec<String>> = None;
let mut working_dir: Option<String> = None;
let mut user: Option<String> = None;
if let Some(base) = base_config_json
&& let Some(bc) = base.get("config")
{
if let Some(env) = bc.get("Env").and_then(|e| e.as_array()) {
for e in env {
if let Some(s) = e.as_str()
&& let Some((k, v)) = s.split_once('=')
{
env_pairs.insert(k.to_string(), v.to_string());
}
}
}
if let Some(c) = bc.get("Cmd").and_then(|c| c.as_array()) {
cmd = Some(
c.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
);
}
if let Some(e) = bc.get("Entrypoint").and_then(|e| e.as_array()) {
entrypoint = Some(
e.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
);
}
if let Some(wd) = bc.get("WorkingDir").and_then(|w| w.as_str())
&& !wd.is_empty()
{
working_dir = Some(wd.to_string());
}
if let Some(u) = bc.get("User").and_then(|u| u.as_str())
&& !u.is_empty()
{
user = Some(u.to_string());
}
}
let env = self
.cfg
.env()
.await
.wrap_err("resolving [env] for oci build (template error, missing file, etc.)")?;
if !env.is_empty() {
warn!(
"mise oci build: baking {} [env] var(s) into the image config. \
These are visible via `docker inspect` / `skopeo inspect`; \
if you have secrets in [env] or referenced .env files, move \
them to runtime (e.g. `docker run -e` or secret mounts) and \
use the [oci].env section for image-only vars.",
env.len()
);
for (k, v) in env {
env_pairs.insert(k, v);
}
}
for (backend, tv) in versions {
let host_install = tv.install_path();
let in_image_root = tool_in_image_path(mount_point, tv);
match backend.exec_env(&self.cfg, &self.ts, tv).await {
Ok(tool_env) => {
for (k, v) in tool_env {
let rebased = rebase_path_value(&v, &host_install, &in_image_root);
env_pairs.insert(k, rebased);
}
}
Err(e) => {
warn!(
"failed to resolve exec_env for {}: {e} — \
any vars that tool needs (e.g. JAVA_HOME) will be missing",
tv.style()
);
}
}
}
for (k, v) in &self.oci.env {
env_pairs.insert(k.clone(), v.clone());
}
env_pairs.insert("MISE_DATA_DIR".to_string(), mount_point.to_string());
env_pairs.insert("MISE_CONFIG_DIR".to_string(), "/etc/mise".to_string());
let mut path_entries: Vec<String> = Vec::new();
for (i, (backend, tv)) in versions.iter().enumerate() {
let install_path = crate::file::canonicalize_or_self(&tv.install_path());
let in_image_tool_root = tool_in_image_path(mount_point, tv);
if tool_reuse[i].is_some() {
let cached_entries = self
.opts
.reuse_from
.as_ref()
.map(|remote| cached_tool_path_entries(remote, &in_image_tool_root))
.unwrap_or_default();
if !cached_entries.is_empty() {
path_entries.extend(cached_entries);
continue;
}
}
let bin_paths = backend
.list_bin_paths(&self.cfg, tv)
.await
.unwrap_or_default();
let mut had_one = false;
for p in bin_paths {
let p = crate::file::canonicalize_or_self(&p);
if let Ok(rel) = p.strip_prefix(&install_path) {
let rel = rel.to_string_lossy();
let entry = if rel.is_empty() {
in_image_tool_root.clone()
} else {
format!("{in_image_tool_root}/{rel}")
};
path_entries.push(entry);
had_one = true;
}
}
if !had_one {
path_entries.push(format!("{in_image_tool_root}/bin"));
}
}
let inherited_path = env_pairs.get("PATH").cloned().unwrap_or_else(|| {
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string()
});
let final_path = if path_entries.is_empty() {
inherited_path
} else {
format!("{}:{}", path_entries.join(":"), inherited_path)
};
env_pairs.insert("PATH".to_string(), final_path);
if let Some(wd) = &self.oci.workdir {
working_dir = Some(wd.clone());
}
if let Some(ep) = &self.oci.entrypoint {
entrypoint = Some(ep.clone());
}
if let Some(c) = &self.oci.cmd {
cmd = Some(c.clone());
}
if let Some(u) = &self.oci.user {
user = Some(u.clone());
}
if working_dir.is_none() {
working_dir = Some("/workspace".to_string());
}
let created = rfc3339_now();
let mut labels: IndexMap<String, String> = IndexMap::new();
labels.insert(
"org.opencontainers.image.created".to_string(),
created.clone(),
);
labels.insert(
"org.opencontainers.image.source".to_string(),
"mise oci build".to_string(),
);
labels.insert(
"dev.mise.version".to_string(),
crate::cli::version::VERSION_PLAIN.to_string(),
);
for (_, tv) in versions {
labels.insert(
format!("dev.mise.tools.{}", sanitize_label(&tv.ba().short)),
tv.version.clone(),
);
}
for (k, v) in &self.oci.labels {
labels.insert(k.clone(), v.clone());
}
let config = ImgConfig {
env: env_pairs.iter().map(|(k, v)| format!("{k}={v}")).collect(),
cmd,
entrypoint,
working_dir,
user,
labels,
exposed_ports: Default::default(),
volumes: Default::default(),
stop_signal: None,
};
let (arch, os) = if let Some(p) = platform {
(p.architecture.clone(), p.os.clone())
} else {
(
crate::oci::normalize_arch(std::env::consts::ARCH).to_string(),
crate::oci::normalize_os(std::env::consts::OS).to_string(),
)
};
Ok(ImageConfig {
created: Some(created),
author: Some("mise".to_string()),
architecture: arch,
os,
variant: None,
config: Some(config),
rootfs: RootFs {
type_: "layers".to_string(),
diff_ids,
},
history: vec![],
})
}
}
fn resolve_layer_owner(opts_owner: Option<LayerOwner>, oci: &OciConfig) -> LayerOwner {
opts_owner.unwrap_or_else(|| {
let uid = oci.user_id.unwrap_or(0);
let gid = oci.group_id.unwrap_or(uid);
LayerOwner::new(uid, gid)
})
}
fn build_dotfiles_layer(
cfg: &Config,
requests: &[FileRequest],
owner: LayerOwner,
) -> Result<LayerBlob> {
let mut entries = DotfilesLayerEntries::default();
for req in requests {
if !matches!(req.mode, FileMode::Content | FileMode::Track) && !req.source.exists() {
bail!(
"[dotfiles].\"{}\": source does not exist: {}",
req.target_raw,
req.source.display()
);
}
match req.mode {
FileMode::Track => continue,
FileMode::Symlink | FileMode::Copy => {
collect_source_as_files(&req.source, &oci_target_path(req)?, &mut entries)
.wrap_err_with(|| {
format!("adding [dotfiles].\"{}\" to OCI image", req.target_raw)
})?;
}
FileMode::SymlinkEach => {
if !req.source.is_dir() {
bail!(
"[dotfiles].\"{}\": mode symlink-each requires a directory source: {}",
req.target_raw,
req.source.display()
);
}
let target = oci_target_path(req)?;
entries.add_dir(target.clone())?;
for entry in walkdir::WalkDir::new(&req.source).sort_by_file_name() {
let entry = entry?;
let ft = entry.file_type();
if !(ft.is_file() || ft.is_symlink()) {
continue;
}
let rel = entry.path().strip_prefix(&req.source)?;
let path = format!("{target}/{}", rel.to_string_lossy().replace('\\', "/"));
entries.add_file(
path,
file::read(entry.path())?,
source_mode(entry.path())?,
)?;
}
}
FileMode::Template => {
let rendered = crate::system::files::render_template(cfg, req)?;
entries.add_file(
oci_target_path(req)?,
rendered.into_bytes(),
source_mode(&req.source)?,
)?;
}
FileMode::Content => {
entries.add_file(
oci_target_path(req)?,
req.content
.as_deref()
.expect("inline content")
.as_bytes()
.to_vec(),
0o600,
)?;
}
}
}
info!("oci: adding {} [dotfiles] entries", requests.len());
let (files, dirs) = entries.into_layer_inputs();
layer::build_layer_from_files_and_dirs(&files, &dirs, owner)
}
fn collect_source_as_files(
source: &std::path::Path,
target: &str,
entries: &mut DotfilesLayerEntries,
) -> Result<()> {
if source.is_dir() {
entries.add_dir(target.to_string())?;
for entry in walkdir::WalkDir::new(source).sort_by_file_name() {
let entry = entry?;
if entry.file_type().is_dir() {
let rel = entry.path().strip_prefix(source)?;
if !rel.as_os_str().is_empty() {
entries.add_dir(format!(
"{target}/{}",
rel.to_string_lossy().replace('\\', "/")
))?;
}
continue;
}
let ft = entry.file_type();
if !(ft.is_file() || ft.is_symlink()) {
warn!(
"oci: skipping non-file [dotfiles] source entry {}",
entry.path().display()
);
continue;
}
let rel = entry.path().strip_prefix(source)?;
let path = format!("{target}/{}", rel.to_string_lossy().replace('\\', "/"));
entries.add_file(path, file::read(entry.path())?, source_mode(entry.path())?)?;
}
} else {
entries.add_file(
target.to_string(),
file::read(source)?,
source_mode(source)?,
)?;
}
Ok(())
}
#[derive(Default)]
struct DotfilesLayerEntries {
files: IndexMap<String, (Vec<u8>, u32)>,
dirs: IndexSet<String>,
}
type DotfilesLayerFile = (String, Vec<u8>, u32);
type DotfilesLayerFiles = Vec<DotfilesLayerFile>;
type DotfilesLayerDirs = Vec<String>;
impl DotfilesLayerEntries {
fn add_file(&mut self, path: String, contents: Vec<u8>, mode: u32) -> Result<()> {
if self.dirs.contains(&path) {
bail!("[dotfiles]: duplicate OCI path {path:?} as both file and directory");
}
if let Some((existing_contents, existing_mode)) = self.files.get(&path) {
if existing_contents != &contents || *existing_mode != mode {
bail!("[dotfiles]: duplicate OCI file path {path:?}");
}
return Ok(());
}
self.files.insert(path, (contents, mode));
Ok(())
}
fn add_dir(&mut self, path: String) -> Result<()> {
if self.files.contains_key(&path) {
bail!("[dotfiles]: duplicate OCI path {path:?} as both file and directory");
}
self.dirs.insert(path);
Ok(())
}
fn into_layer_inputs(self) -> (DotfilesLayerFiles, DotfilesLayerDirs) {
let files = self
.files
.into_iter()
.map(|(path, (contents, mode))| (path, contents, mode))
.collect();
let dirs = self.dirs.into_iter().collect();
(files, dirs)
}
}
fn oci_target_path(req: &FileRequest) -> Result<String> {
let raw = req.target_raw.as_str();
let path = if raw == "~" {
"root".to_string()
} else if let Some(rest) = raw.strip_prefix("~/") {
format!("root/{rest}")
} else {
req.target
.strip_prefix("/")
.map_err(|_| eyre::eyre!("dotfile target must be absolute: {}", req.target_raw))?
.to_string_lossy()
.replace('\\', "/")
};
if path.is_empty() || path.split('/').any(|p| p == "..") {
bail!(
"[dotfiles].\"{}\": target is not a safe OCI path",
req.target_raw
);
}
Ok(path)
}
fn source_mode(path: &std::path::Path) -> Result<u32> {
let md = path.metadata()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(md.permissions().mode() & 0o7777)
}
#[cfg(not(unix))]
{
let _ = md;
Ok(0o644)
}
}
fn reject_unsupported_backends(
versions: &[(Arc<dyn crate::backend::Backend>, ToolVersion)],
) -> Result<()> {
let bad: Vec<String> = versions
.iter()
.filter_map(|(backend, tv)| match backend.get_type() {
BackendType::Asdf | BackendType::Vfox | BackendType::VfoxBackend(_) => {
Some(tv.ba().short.clone())
}
_ => None,
})
.collect();
if !bad.is_empty() {
bail!(
"mise oci build does not support asdf/vfox plugins in v1 (their install scripts can \
write outside the per-version directory, breaking the one-layer-per-tool invariant). \
Affected tools: {}",
bad.join(", ")
);
}
Ok(())
}
fn rebase_path_value(value: &str, host_prefix: &std::path::Path, in_image_prefix: &str) -> String {
let host: &str = &host_prefix.to_string_lossy();
if host.is_empty() || !value.contains(host) {
return value.to_string();
}
value.replace(host, in_image_prefix)
}
fn tool_in_image_path(mount_point: &str, tv: &ToolVersion) -> String {
let plugin_dir = tv.ba().tool_dir_name();
let version_dir = tv.tv_pathname();
format!("{mount_point}/installs/{plugin_dir}/{version_dir}")
}
fn tool_tar_prefix(mount_point: &str, tv: &ToolVersion) -> String {
tool_in_image_path(mount_point, tv)
.trim_start_matches('/')
.to_string()
}
fn tool_layer_relocation_key(tv: &ToolVersion, pythons: &[PythonRelocation]) -> String {
if tv.ba().backend_type() == BackendType::Pipx {
format!(
"{TOOL_LAYER_RELOCATION_VERSION}:python={}",
python_relocation_fingerprint(pythons)
)
} else {
TOOL_LAYER_RELOCATION_VERSION.to_string()
}
}
fn is_python_backend(backend: &dyn crate::backend::Backend) -> bool {
backend.get_type() == BackendType::Core && backend.tool_name() == "python"
}
fn python_relocation_fingerprint(pythons: &[PythonRelocation]) -> String {
let mut pythons = pythons.to_vec();
pythons.sort_by(|a, b| (&a.version, &a.host, &a.image).cmp(&(&b.version, &b.host, &b.image)));
let mut hash = Sha256::new();
for python in pythons {
for input in [
python.version,
python.host.to_string_lossy().into_owned(),
python.image.to_string_lossy().into_owned(),
] {
hash.update((input.len() as u64).to_le_bytes());
hash.update(input.as_bytes());
}
}
layer::hex_encode(&hash.finalize())
}
fn synthesize_embedded_config_toml(
versions: &[(Arc<dyn crate::backend::Backend>, ToolVersion)],
_mount_point: &str,
) -> String {
let mut s = String::from("# Auto-generated by `mise oci build`. Do not edit.\n[tools]\n");
for (_, tv) in versions {
let mut tbl = toml::value::Table::new();
tbl.insert(
tv.ba().short.clone(),
toml::Value::String(tv.version.clone()),
);
let rendered = toml::to_string(&tbl).unwrap_or_default();
s.push_str(&rendered);
}
s
}
fn rfc3339_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = if let Ok(s) = std::env::var("SOURCE_DATE_EPOCH") {
match s.parse::<u64>() {
Ok(n) => n,
Err(_) => {
warn!(
"ignoring SOURCE_DATE_EPOCH={s:?}: not a non-negative integer. \
Using the system clock instead."
);
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
}
} else {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
format_rfc3339_utc(secs)
}
fn format_rfc3339_utc(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let time_of_day = secs % 86_400;
let h = time_of_day / 3600;
let m = (time_of_day / 60) % 60;
let s = time_of_day % 60;
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn days_to_ymd(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn sanitize_label(s: &str) -> String {
s.replace([':', '/'], ".")
}
fn cached_tool_path_entries(remote: ®istry::RemoteImage, tool_root: &str) -> Vec<String> {
remote
.config
.get("config")
.and_then(|config| config.get("Env"))
.and_then(|env| env.as_array())
.and_then(|env| {
env.iter()
.filter_map(|entry| entry.as_str())
.filter_map(|entry| entry.strip_prefix("PATH="))
.next_back()
})
.map(|path| {
path.split(':')
.filter(|entry| {
let has_parent = PathBuf::from(entry)
.components()
.any(|component| component == std::path::Component::ParentDir);
!has_parent
&& (*entry == tool_root
|| entry
.strip_prefix(tool_root)
.is_some_and(|suffix| suffix.starts_with('/')))
})
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn layer(annotations: &[(&str, &str)], digest: &str) -> Descriptor {
Descriptor {
media_type: manifest::MEDIA_TYPE_OCI_LAYER_GZIP.to_string(),
size: 100,
digest: digest.to_string(),
annotations: annotations
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
platform: None,
}
}
#[test]
fn recognizes_an_alias_resolved_to_core_python() {
let alias =
crate::cli::args::BackendArg::new("py".to_string(), Some("core:python".to_string()));
let backend = crate::backend::arg_to_backend(alias).unwrap();
assert!(is_python_backend(backend.as_ref()));
}
#[test]
fn python_relocation_fingerprint_covers_exact_inputs_and_not_order() {
let python_313 = PythonRelocation {
version: "3.13.9".into(),
host: "/host/python/3.13.9".into(),
image: "/mise/installs/python/3.13.9".into(),
};
let python_314 = PythonRelocation {
version: "3.14.3".into(),
host: "/host/python/3.14.3".into(),
image: "/mise/installs/python/3.14.3".into(),
};
let fingerprint = python_relocation_fingerprint(&[python_313.clone(), python_314.clone()]);
assert_eq!(
fingerprint,
python_relocation_fingerprint(&[python_314.clone(), python_313.clone()])
);
assert_ne!(fingerprint, python_relocation_fingerprint(&[python_314]));
let python_313_fingerprint =
python_relocation_fingerprint(std::slice::from_ref(&python_313));
let mut changed_target = python_313;
changed_target.image = "/opt/python/3.13.9".into();
assert_ne!(
python_313_fingerprint,
python_relocation_fingerprint(&[changed_target])
);
}
#[test]
fn reuse_index_keys_on_all_relocation_annotations() {
let full = [
(ANNOTATION_TOOL_SHORT, "jq"),
(ANNOTATION_TOOL_VERSION, "1.8.1"),
(ANNOTATION_LAYER_PREFIX, "mise/installs/jq/1.8.1"),
(ANNOTATION_LAYER_OWNER, "0:0"),
(ANNOTATION_LAYER_RELOCATION, TOOL_LAYER_RELOCATION_VERSION),
];
let partial = [
(ANNOTATION_TOOL_SHORT, "node"),
(ANNOTATION_TOOL_VERSION, "20.0.0"),
];
let remote = registry::RemoteImage {
manifest: ImageManifest {
schema_version: 2,
media_type: manifest::MEDIA_TYPE_OCI_MANIFEST.to_string(),
config: layer(&[], "sha256:cfg"),
layers: vec![layer(&full, "sha256:aaa"), layer(&partial, "sha256:bbb")],
annotations: Default::default(),
},
diff_ids: vec!["sha256:diff-a".into(), "sha256:diff-b".into()],
config: serde_json::json!({}),
};
let index = build_reuse_index(&remote);
assert_eq!(index.len(), 1);
let hit = index
.get(&ReuseKey {
short: "jq".into(),
version: "1.8.1".into(),
prefix: "mise/installs/jq/1.8.1".into(),
owner: "0:0".into(),
relocation: TOOL_LAYER_RELOCATION_VERSION.into(),
})
.unwrap();
assert_eq!(hit.digest, "sha256:aaa");
assert_eq!(hit.diff_id, "sha256:diff-a");
assert!(
index
.get(&ReuseKey {
short: "jq".into(),
version: "1.8.1".into(),
prefix: "mise/installs/jq/1.8.1".into(),
owner: "1000:1000".into(),
relocation: TOOL_LAYER_RELOCATION_VERSION.into(),
})
.is_none()
);
}
#[test]
fn cached_path_entries_are_scoped_to_the_reused_tool() {
let remote = registry::RemoteImage {
manifest: ImageManifest {
schema_version: 2,
media_type: manifest::MEDIA_TYPE_OCI_MANIFEST.to_string(),
config: layer(&[], "sha256:cfg"),
layers: vec![],
annotations: Default::default(),
},
diff_ids: vec![],
config: serde_json::json!({
"config": {
"Env": [
"PATH=/mise/installs/pnpm/9.15.9:/mise/installs/deno/2.0.0/bin:/mise/installs/pnpm/9.15.9/../../other/bin:/usr/bin"
]
}
}),
};
assert_eq!(
cached_tool_path_entries(&remote, "/mise/installs/pnpm/9.15.9"),
vec!["/mise/installs/pnpm/9.15.9"]
);
assert_eq!(
cached_tool_path_entries(&remote, "/mise/installs/deno/2.0.0"),
vec!["/mise/installs/deno/2.0.0/bin"]
);
}
#[test]
fn resolve_layer_owner_defaults_to_root() {
assert_eq!(
resolve_layer_owner(None, &OciConfig::default()),
LayerOwner::new(0, 0)
);
}
#[test]
fn resolve_layer_owner_uses_config_with_uid_as_gid_default() {
let oci = OciConfig {
user_id: Some(1000),
..Default::default()
};
assert_eq!(resolve_layer_owner(None, &oci), LayerOwner::new(1000, 1000));
}
#[test]
fn resolve_layer_owner_uses_config_group_id_when_present() {
let oci = OciConfig {
user_id: Some(1000),
group_id: Some(1001),
..Default::default()
};
assert_eq!(resolve_layer_owner(None, &oci), LayerOwner::new(1000, 1001));
}
#[test]
fn resolve_layer_owner_uses_merged_config_group_id_from_same_layer() {
let mut oci = OciConfig::default();
oci.fill_defaults_from(OciConfig {
user_id: Some(1000),
group_id: Some(1001),
..Default::default()
});
assert_eq!(resolve_layer_owner(None, &oci), LayerOwner::new(1000, 1001));
}
#[test]
fn resolve_layer_owner_does_not_inherit_less_specific_group_after_user_override() {
let mut oci = OciConfig {
user_id: Some(1000),
..Default::default()
};
oci.fill_defaults_from(OciConfig {
group_id: Some(2000),
..Default::default()
});
assert_eq!(resolve_layer_owner(None, &oci), LayerOwner::new(1000, 1000));
}
#[test]
fn resolve_layer_owner_cli_value_wins_over_config() {
let oci = OciConfig {
user_id: Some(1000),
group_id: Some(1001),
..Default::default()
};
assert_eq!(
resolve_layer_owner(Some(LayerOwner::new(2000, 2001)), &oci),
LayerOwner::new(2000, 2001)
);
}
}