From 78aa59c557fa6fef8ee88fb291a7cf80d74c6816 Mon Sep 17 00:00:00 2001
From: Alex Huszagh <ahuszagh@gmail.com>
Date: Thu, 30 Jun 2022 15:53:03 -0500
Subject: [PATCH] Add a `zig cc`-based image.

Uses cargo-zigbuild as a backend, and adds configuration options for zig under `[build.zig]` and `[target.(...).zig]`. If enabled, and an image override is not provided, `cross` will always use the `zig` image. The feature can be enabled by providing `zig` as a table, bool, or string.

It supports custom glibc versions by passing the `zig.version` key, and `zig` can be separately enabled or disabled by providing `zig.enable`.

```
[target.x86_64-unknown-linux-gnu.zig]
enable = true       # enable use of the zig image
version = "2.17"    # glibc version to use
image = "ghcr.io/cross-rs/zig:local"    # custom image to use
```

If provided as a bool, it will use the default glibc version:

```
[target.x86_64-unknown-linux-gnu]
\# equivalent to { enable = true }
zig = true
```

If provided as a string, `zig` will be automatically enabled:

```
[target.x86_64-unknown-linux-gnu]
\# equivalent to { enable = true, version = "2.17" }
zig = "2.17"
```

The image does not provide runners, `bindgen` Clang args, or `pkg-config` paths, since `zig cc` does not provide the dynamic library loader (`ld-linux*.so`) required, meaning none of the binaries can be run. For `bindgen`, `zig cc` has an unusual directory structure, so there is no traditional sysroot with `usr`, `lib`, and `include` subdirectories. Finally, since we don't have system packages we can work with, exporting a `pkg-config` path makes little sense.

Closes #860.
---
 .github/workflows/ci.yml |   9 +-
 CHANGELOG.md             |   1 +
 ci/shared.sh             |  23 +++++
 ci/test-zig-image.sh     |  55 +++++++++++
 ci/test.sh               |  25 +----
 docker/Dockerfile.zig    |  22 +++++
 docker/zig.sh            | 197 +++++++++++++++++++++++++++++++++++++++
 docs/cross_toml.md       |  24 +++++
 src/config.rs            |  36 ++++++-
 src/cross_toml.rs        | 147 +++++++++++++++++++++++++++++
 src/docker/custom.rs     |   3 +-
 src/docker/local.rs      |  28 ++++--
 src/docker/mod.rs        |   8 +-
 src/docker/remote.rs     |  16 +++-
 src/docker/shared.rs     |  33 +++++--
 src/lib.rs               |  57 +++++++++--
 xtask/src/ci.rs          |   5 +
 xtask/src/util.rs        |   5 +
 18 files changed, 633 insertions(+), 61 deletions(-)
 create mode 100644 ci/shared.sh
 create mode 100755 ci/test-zig-image.sh
 create mode 100644 docker/Dockerfile.zig
 create mode 100755 docker/zig.sh

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37c099c10..75ee013d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -207,6 +207,7 @@ jobs:
             - { target: thumbv7em-none-eabi,              os: ubuntu-latest,                    std: 1 }
             - { target: thumbv7em-none-eabihf,            os: ubuntu-latest,                    std: 1 }
             - { target: thumbv7m-none-eabi,               os: ubuntu-latest,                    std: 1 }
+            - { target: zig,                              os: ubuntu-latest }
 
   build:
     name: target (${{ matrix.pretty }},${{ matrix.os }})
@@ -283,7 +284,7 @@ jobs:
           IMAGE: ${{ steps.build-docker-image.outputs.image }}
         shell: bash
       - name: Test Image
-        if: steps.prepare-meta.outputs.has-image
+        if: steps.prepare-meta.outputs.has-image && steps.prepare-meta.outputs.test-variant == 'default'
         run: ./ci/test.sh
         env:
           TARGET: ${{ matrix.target }}
@@ -294,6 +295,12 @@ jobs:
           RUN: ${{ matrix.run }}
           RUNNERS: ${{ matrix.runners }}
         shell: bash
+
+      - name: Test Zig Image
+        if: steps.prepare-meta.outputs.has-image && steps.prepare-meta.outputs.test-variant == 'zig'
+        run: ./ci/test-zig-image.sh
+        shell: bash
+
       - uses: ./.github/actions/cargo-install-upload-artifacts
         if: matrix.deploy
         with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c1d2d821..c8a57a310 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
 ### Added
 
 - #890 - support rootless docker via the `CROSS_ROOTLESS_CONTAINER_ENGINE` environment variable.
+- #880 - added a zig-based image, allowing multiple targets to be built from the same image, using cargo-zigbuild.
 
 ### Changed
 
diff --git a/ci/shared.sh b/ci/shared.sh
new file mode 100644
index 000000000..457861dd7
--- /dev/null
+++ b/ci/shared.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+
+function retry {
+  local tries="${TRIES-5}"
+  local timeout="${TIMEOUT-1}"
+  local try=0
+  local exit_code=0
+
+  while (( try < tries )); do
+    if "${@}"; then
+      return 0
+    else
+      exit_code=$?
+    fi
+
+    sleep "${timeout}"
+    echo "Retrying ..." 1>&2
+    try=$(( try + 1 ))
+    timeout=$(( timeout * 2 ))
+  done
+
+  return ${exit_code}
+}
diff --git a/ci/test-zig-image.sh b/ci/test-zig-image.sh
new file mode 100755
index 000000000..a2c8d30ad
--- /dev/null
+++ b/ci/test-zig-image.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# shellcheck disable=SC2086,SC1091,SC1090
+
+set -x
+set -eo pipefail
+
+# NOTE: "${@}" is an unbound variable for bash 3.2, which is the
+# installed version on macOS. likewise, "${var[@]}" is an unbound
+# error if var is an empty array.
+
+ci_dir=$(dirname "${BASH_SOURCE[0]}")
+ci_dir=$(realpath "${ci_dir}")
+project_home=$(dirname "${ci_dir}")
+. "${ci_dir}"/shared.sh
+
+# zig cc is very slow: only use a few targets.
+TARGETS=(
+    "aarch64-unknown-linux-gnu"
+    "aarch64-unknown-linux-musl"
+    "i586-unknown-linux-gnu"
+    "i586-unknown-linux-musl"
+)
+
+# on CI, it sets `CROSS_TARGET_ZIG_IMAGE` rather than `CROSS_TARGET_ZIG_IMAGE`
+if [[ -n "${CROSS_TARGET_ZIG_IMAGE}" ]]; then
+    export CROSS_BUILD_ZIG_IMAGE="${CROSS_TARGET_ZIG_IMAGE}"
+    unset CROSS_TARGET_ZIG_IMAGE
+fi
+
+main() {
+    export CROSS_BUILD_ZIG=1
+
+    local td=
+    local target=
+
+    retry cargo fetch
+    cargo build
+    export CROSS="${project_home}/target/debug/cross"
+
+    td="$(mktemp -d)"
+    git clone --depth 1 https://github.com/cross-rs/rust-cpp-hello-word "${td}"
+    pushd "${td}"
+
+    for target in "${TARGETS[@]}"; do
+        "${CROSS}" build --target "${target}" --verbose
+        # note: ensure #724 doesn't replicate during CI.
+        # https://github.com/cross-rs/cross/issues/724
+        cargo clean
+    done
+
+    popd
+    rm -rf "${td}"
+}
+
+main "${@}"
diff --git a/ci/test.sh b/ci/test.sh
index 4b9b494e1..fbfaddaa5 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -1,5 +1,5 @@
 #!/usr/bin/env bash
-# shellcheck disable=SC2086
+# shellcheck disable=SC2086,SC1091,SC1090
 
 set -x
 set -euo pipefail
@@ -10,30 +10,9 @@ set -euo pipefail
 
 ci_dir=$(dirname "${BASH_SOURCE[0]}")
 ci_dir=$(realpath "${ci_dir}")
+. "${ci_dir}"/shared.sh
 project_home=$(dirname "${ci_dir}")
 
-function retry {
-  local tries="${TRIES-5}"
-  local timeout="${TIMEOUT-1}"
-  local try=0
-  local exit_code=0
-
-  while (( try < tries )); do
-    if "${@}"; then
-      return 0
-    else
-      exit_code=$?
-    fi
-
-    sleep "${timeout}"
-    echo "Retrying ..." 1>&2
-    try=$(( try + 1 ))
-    timeout=$(( timeout * 2 ))
-  done
-
-  return ${exit_code}
-}
-
 workspace_test() {
   "${CROSS[@]}" build --target "${TARGET}" --workspace "$@" ${CROSS_FLAGS}
   "${CROSS[@]}" run --target "${TARGET}" -p binary "$@" ${CROSS_FLAGS}
diff --git a/docker/Dockerfile.zig b/docker/Dockerfile.zig
new file mode 100644
index 000000000..ebca27452
--- /dev/null
+++ b/docker/Dockerfile.zig
@@ -0,0 +1,22 @@
+FROM ubuntu:20.04
+ARG DEBIAN_FRONTEND=noninteractive
+
+COPY common.sh lib.sh /
+RUN /common.sh
+
+COPY cmake.sh /
+RUN /cmake.sh
+
+COPY xargo.sh /
+RUN /xargo.sh
+
+ARG TARGETPLATFORM
+COPY zig.sh /
+RUN /zig.sh $TARGETPLATFORM
+
+# we don't export `BINDGEN_EXTRA_CLANG_ARGS`, `QEMU_LD_PREFIX`, or
+# `PKG_CONFIG_PATH` since zig doesn't have a traditional sysroot structure,
+# and we're not using standard, shared packages. none of the packages
+# have runners either, since they do not ship with the required
+# dynamic linker (`ld-linux-${arch}.so`).
+ENV PATH=$PATH:/opt/zig
diff --git a/docker/zig.sh b/docker/zig.sh
new file mode 100755
index 000000000..d41ee3742
--- /dev/null
+++ b/docker/zig.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env bash
+
+set -x
+set -eo pipefail
+
+# shellcheck disable=SC1091
+. lib.sh
+
+main() {
+    local platform="${1}"
+    install_packages ca-certificates curl xz-utils
+
+    install_zig "${platform}"
+    install_zigbuild "${platform}"
+
+    purge_packages
+    rm "${0}"
+}
+
+install_zig() {
+    local platform="${1}"
+    local version="0.9.1"
+    local dst="/opt/zig"
+    local arch=
+    local os=
+    local triple=
+
+    case "${platform}" in
+        'linux/386')
+            arch="i386"
+            os="linux"
+            ;;
+        'linux/amd64')
+            arch="x86_64"
+            os="linux"
+            ;;
+        'linux/arm64')
+            arch="aarch64"
+            os="linux"
+            ;;
+        'linux/riscv64')
+            arch="riscv64"
+            os="linux"
+            ;;
+        'linux/ppc64le')
+            triple="powerpc64le-linux-gnu"
+            ;;
+        'linux/s390x')
+            triple="s390x-linux-gnu"
+            ;;
+        'darwin/amd64')
+            arch="x86_64"
+            os="macos"
+            ;;
+        'darwin/arm64')
+            arch="aarch64"
+            os="macos"
+            ;;
+        # NOTE: explicitly don't support linux/arm/v6
+        *)
+            echo "Unsupported target platform '${platform}'" 1>&2
+            exit 1
+            ;;
+    esac
+
+    if [[ -n "${arch}" ]]; then
+        install_zig_tarball "${arch}" "${os}" "${version}" "${dst}"
+    else
+        install_zig_source "${triple}" "${version}" "${dst}"
+    fi
+}
+
+install_zig_tarball() {
+    local arch="${1}"
+    local os="${2}"
+    local version="${3}"
+    local dst="${4}"
+    local filename="zig-${os}-${arch}-${version}.tar.xz"
+
+    local td
+    td="$(mktemp -d)"
+
+    pushd "${td}"
+
+    curl --retry 3 -sSfL "https://ziglang.org/download/${version}/${filename}" -O
+    mkdir -p "${dst}"
+    tar --strip-components=1 -xJf "${filename}" --directory "${dst}"
+
+    popd
+
+    rm -rf "${td}"
+}
+
+install_zig_source() {
+    local triple="${1}"
+    local version="${2}"
+    local dst="${3}"
+    local filename="zig-bootstrap-${version}.tar.xz"
+
+    local td
+    td="$(mktemp -d)"
+
+    pushd "${td}"
+
+    curl --retry 3 -sSfL "https://ziglang.org/download/${version}/${filename}" -O
+    mkdir zig
+    tar --strip-components=1 -xJf "${filename}" --directory zig
+
+    pushd zig
+    install_packages python3 make g++
+    ./build -j5 "${triple}" native
+    mv "out/zig-${triple}-native" /opt/zig
+
+    popd
+    popd
+
+    rm -rf "${td}"
+}
+
+install_zigbuild() {
+    local platform="${1}"
+    local version=0.11.0
+    local dst="/usr/local"
+    local triple=
+
+    # we don't know if `linux/arm/v7` is hard-float,
+    # and we don't know the the zigbuild `apple-darwin`
+    # target doesn't manually specify the architecture.
+    case "${platform}" in
+        'linux/386')
+            triple="i686-unknown-linux-musl"
+            ;;
+        'linux/amd64')
+            triple="x86_64-unknown-linux-musl"
+            ;;
+        'linux/arm64')
+            triple="aarch64-unknown-linux-musl"
+            ;;
+        *)
+            ;;
+    esac
+
+    if [[ -n "${triple}" ]]; then
+        install_zigbuild_tarball "${triple}" "${version}" "${dst}"
+    else
+        install_zigbuild_source "${version}" "${dst}"
+    fi
+}
+
+install_zigbuild_tarball() {
+    local triple="${1}"
+    local version="${2}"
+    local dst="${3}"
+    local repo="https://github.com/messense/cargo-zigbuild"
+    local filename="cargo-zigbuild-v${version}.${triple}.tar.gz"
+
+    local td
+    td="$(mktemp -d)"
+
+    pushd "${td}"
+
+    curl --retry 3 -sSfL "${repo}/releases/download/v${version}/${filename}" -O
+    mkdir -p "${dst}/bin"
+    tar -xzf "${filename}" --directory "${dst}/bin"
+
+    popd
+
+    rm -rf "${td}"
+}
+
+install_zigbuild_source() {
+    local version="${1}"
+    local dst="${2}"
+
+    local td
+    td="$(mktemp -d)"
+
+    pushd "${td}"
+
+    export RUSTUP_HOME="${td}/rustup"
+    export CARGO_HOME="${td}/cargo"
+
+    curl --retry 3 -sSfL https://sh.rustup.rs -o rustup-init.sh
+    sh rustup-init.sh -y --no-modify-path --profile minimal
+
+    PATH="${CARGO_HOME}/bin:${PATH}" \
+        cargo install cargo-zigbuild \
+        --version "${version}" \
+        --root "${dst}" \
+        --locked
+
+    popd
+
+    rm -rf "${td}"
+}
+
+main "${@}"
diff --git a/docs/cross_toml.md b/docs/cross_toml.md
index 51f878302..bde0ce189 100644
--- a/docs/cross_toml.md
+++ b/docs/cross_toml.md
@@ -33,6 +33,7 @@ The `target` key allows you to specify parameters for specific compilation targe
 [target.aarch64-unknown-linux-gnu]
 xargo = false
 build-std = false
+zig = "2.17"
 image = "test-image"
 pre-build = ["apt-get update"]
 runner = "custom-runner"
@@ -64,3 +65,26 @@ also supports
 [target.x86_64-unknown-linux-gnu]
 dockerfile = "./Dockerfile"
 ```
+
+# `target.TARGET.zig`
+
+```toml
+[target.x86_64-unknown-linux-gnu.zig]
+enable = true       # enable use of the zig image
+version = "2.17"    # glibc version to use
+image = "zig:local" # custom zig image to use
+```
+
+also supports
+
+```toml
+[target.x86_64-unknown-linux-gnu]
+zig = true
+```
+
+or
+
+```toml
+[target.x86_64-unknown-linux-gnu]
+zig = "2.17"
+```
diff --git a/src/config.rs b/src/config.rs
index 4c2ae734d..959a8515e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -65,6 +65,20 @@ impl Environment {
         self.get_values_for("BUILD_STD", target, bool_from_envvar)
     }
 
+    fn zig(&self, target: &Target) -> (Option<bool>, Option<bool>) {
+        self.get_values_for("ZIG", target, bool_from_envvar)
+    }
+
+    fn zig_version(&self, target: &Target) -> Option<String> {
+        let res = self.get_values_for("ZIG_VERSION", target, str::to_string);
+        res.0.or(res.1)
+    }
+
+    fn zig_image(&self, target: &Target) -> Option<String> {
+        let res = self.get_values_for("ZIG_IMAGE", target, str::to_string);
+        res.0.or(res.1)
+    }
+
     fn image(&self, target: &Target) -> Option<String> {
         self.get_target_var(target, "IMAGE")
     }
@@ -244,6 +258,18 @@ impl Config {
         self.bool_from_config(target, Environment::build_std, CrossToml::build_std)
     }
 
+    pub fn zig(&self, target: &Target) -> Option<bool> {
+        self.bool_from_config(target, Environment::zig, CrossToml::zig)
+    }
+
+    pub fn zig_version(&self, target: &Target) -> Result<Option<String>> {
+        self.string_from_config(target, Environment::zig_version, CrossToml::zig_version)
+    }
+
+    pub fn zig_image(&self, target: &Target) -> Result<Option<String>> {
+        self.string_from_config(target, Environment::zig_image, CrossToml::zig_image)
+    }
+
     pub fn image(&self, target: &Target) -> Result<Option<String>> {
         self.string_from_config(target, Environment::image, CrossToml::image)
     }
@@ -378,20 +404,29 @@ mod tests {
             let mut map = std::collections::HashMap::new();
             map.insert("CROSS_BUILD_XARGO", "tru");
             map.insert("CROSS_BUILD_STD", "false");
+            map.insert("CROSS_BUILD_ZIG_IMAGE", "zig:local");
 
             let env = Environment::new(Some(map));
             assert_eq!(env.xargo(&target()), (Some(true), None));
             assert_eq!(env.build_std(&target()), (Some(false), None));
+            assert_eq!(env.zig(&target()), (None, None));
+            assert_eq!(env.zig_version(&target()), None);
+            assert_eq!(env.zig_image(&target()), Some("zig:local".to_string()));
         }
 
         #[test]
         pub fn build_and_target_set_returns_tuple() {
             let mut map = std::collections::HashMap::new();
             map.insert("CROSS_BUILD_XARGO", "true");
+            map.insert("CROSS_BUILD_ZIG", "true");
+            map.insert("CROSS_BUILD_ZIG_VERSION", "2.17");
             map.insert("CROSS_TARGET_AARCH64_UNKNOWN_LINUX_GNU_XARGO", "false");
 
             let env = Environment::new(Some(map));
             assert_eq!(env.xargo(&target()), (Some(true), Some(false)));
+            assert_eq!(env.zig(&target()), (Some(true), None));
+            assert_eq!(env.zig_version(&target()), Some("2.17".into()));
+            assert_eq!(env.zig_image(&target()), None);
         }
 
         #[test]
@@ -527,7 +562,6 @@ mod tests {
                 config.env_volumes(&target())?,
                 Some(vec![s!("VOLUME3"), s!("VOLUME4")])
             );
-            // TODO(ahuszagh) Need volumes
 
             Ok(())
         }
diff --git a/src/cross_toml.rs b/src/cross_toml.rs
index 0eed16216..5ce1990f5 100644
--- a/src/cross_toml.rs
+++ b/src/cross_toml.rs
@@ -23,6 +23,8 @@ pub struct CrossBuildConfig {
     env: CrossEnvConfig,
     xargo: Option<bool>,
     build_std: Option<bool>,
+    #[serde(default, deserialize_with = "opt_string_bool_or_struct")]
+    zig: Option<CrossZigConfig>,
     default_target: Option<String>,
     pre_build: Option<Vec<String>>,
     #[serde(default, deserialize_with = "opt_string_or_struct")]
@@ -35,6 +37,8 @@ pub struct CrossBuildConfig {
 pub struct CrossTargetConfig {
     xargo: Option<bool>,
     build_std: Option<bool>,
+    #[serde(default, deserialize_with = "opt_string_bool_or_struct")]
+    zig: Option<CrossZigConfig>,
     image: Option<String>,
     #[serde(default, deserialize_with = "opt_string_or_struct")]
     dockerfile: Option<CrossTargetDockerfileConfig>,
@@ -65,6 +69,43 @@ impl FromStr for CrossTargetDockerfileConfig {
     }
 }
 
+/// Zig configuration
+#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub struct CrossZigConfig {
+    enable: Option<bool>,
+    version: Option<String>,
+    image: Option<String>,
+}
+
+impl From<&str> for CrossZigConfig {
+    fn from(s: &str) -> CrossZigConfig {
+        CrossZigConfig {
+            enable: Some(true),
+            version: Some(s.to_string()),
+            image: None,
+        }
+    }
+}
+
+impl From<bool> for CrossZigConfig {
+    fn from(s: bool) -> CrossZigConfig {
+        CrossZigConfig {
+            enable: Some(s),
+            version: None,
+            image: None,
+        }
+    }
+}
+
+impl FromStr for CrossZigConfig {
+    type Err = std::convert::Infallible;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(s.into())
+    }
+}
+
 /// Cross configuration
 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
 pub struct CrossToml {
@@ -277,6 +318,33 @@ impl CrossToml {
         self.get_bool(target, |b| b.build_std, |t| t.build_std)
     }
 
+    /// Returns the `{}.zig` or `{}.zig.version` part of `Cross.toml`
+    pub fn zig(&self, target: &Target) -> (Option<bool>, Option<bool>) {
+        self.get_bool(
+            target,
+            |b| b.zig.as_ref().and_then(|z| z.enable),
+            |t| t.zig.as_ref().and_then(|z| z.enable),
+        )
+    }
+
+    /// Returns the `{}.zig` or `{}.zig.version` part of `Cross.toml`
+    pub fn zig_version(&self, target: &Target) -> Option<String> {
+        self.get_string(
+            target,
+            |b| b.zig.as_ref().and_then(|c| c.version.as_ref()),
+            |t| t.zig.as_ref().and_then(|c| c.version.as_ref()),
+        )
+    }
+
+    /// Returns the  `{}.zig.image` part of `Cross.toml`
+    pub fn zig_image(&self, target: &Target) -> Option<String> {
+        self.get_string(
+            target,
+            |b| b.zig.as_ref().and_then(|c| c.image.as_ref()),
+            |t| t.zig.as_ref().and_then(|c| c.image.as_ref()),
+        )
+    }
+
     /// Returns the list of environment variables to pass through for `build` and `target`
     pub fn env_passthrough(&self, target: &Target) -> (Option<&[String]>, Option<&[String]>) {
         self.get_vec(
@@ -402,6 +470,68 @@ where
     deserializer.deserialize_any(StringOrStruct(PhantomData))
 }
 
+fn opt_string_bool_or_struct<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
+where
+    T: Deserialize<'de> + From<bool> + std::str::FromStr<Err = std::convert::Infallible>,
+    D: serde::Deserializer<'de>,
+{
+    use std::{fmt, marker::PhantomData};
+
+    use serde::de::{self, MapAccess, Visitor};
+
+    struct StringBoolOrStruct<T>(PhantomData<fn() -> T>);
+
+    impl<'de, T> Visitor<'de> for StringBoolOrStruct<T>
+    where
+        T: Deserialize<'de> + From<bool> + std::str::FromStr<Err = std::convert::Infallible>,
+    {
+        type Value = Option<T>;
+
+        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+            formatter.write_str("string, bool, or map")
+        }
+
+        fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
+        where
+            E: de::Error,
+        {
+            Ok(Some(From::from(value)))
+        }
+
+        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
+        where
+            E: de::Error,
+        {
+            Ok(FromStr::from_str(value).ok())
+        }
+
+        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
+        where
+            M: MapAccess<'de>,
+        {
+            let t: Result<T, _> =
+                Deserialize::deserialize(de::value::MapAccessDeserializer::new(map));
+            t.map(Some)
+        }
+
+        fn visit_none<E>(self) -> Result<Self::Value, E>
+        where
+            E: de::Error,
+        {
+            Ok(None)
+        }
+
+        fn visit_unit<E>(self) -> Result<Self::Value, E>
+        where
+            E: de::Error,
+        {
+            Ok(None)
+        }
+    }
+
+    deserializer.deserialize_any(StringBoolOrStruct(PhantomData))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -441,6 +571,7 @@ mod tests {
                 },
                 xargo: Some(true),
                 build_std: None,
+                zig: None,
                 default_target: None,
                 pre_build: Some(vec!["echo 'Hello World!'".to_string()]),
                 dockerfile: None,
@@ -478,6 +609,7 @@ mod tests {
                 },
                 xargo: Some(false),
                 build_std: Some(true),
+                zig: None,
                 image: Some("test-image".to_string()),
                 runner: None,
                 dockerfile: None,
@@ -518,6 +650,11 @@ mod tests {
             CrossTargetConfig {
                 xargo: Some(false),
                 build_std: None,
+                zig: Some(CrossZigConfig {
+                    enable: None,
+                    version: None,
+                    image: Some("zig:local".to_string()),
+                }),
                 image: None,
                 dockerfile: Some(CrossTargetDockerfileConfig {
                     file: "Dockerfile.test".to_string(),
@@ -542,6 +679,11 @@ mod tests {
                 },
                 xargo: Some(true),
                 build_std: None,
+                zig: Some(CrossZigConfig {
+                    enable: Some(true),
+                    version: Some("2.17".to_string()),
+                    image: None,
+                }),
                 default_target: None,
                 pre_build: Some(vec![]),
                 dockerfile: None,
@@ -551,6 +693,7 @@ mod tests {
         let test_str = r#"
             [build]
             xargo = true
+            zig = "2.17"
             pre-build = []
 
             [build.env]
@@ -561,6 +704,9 @@ mod tests {
             dockerfile = "Dockerfile.test"
             pre-build = ["echo 'Hello'"]
 
+            [target.aarch64-unknown-linux-gnu.zig]
+            image = "zig:local"
+
             [target.aarch64-unknown-linux-gnu.env]
             volumes = ["VOL"]
         "#;
@@ -600,6 +746,7 @@ mod tests {
                 },
                 build_std: None,
                 xargo: Some(true),
+                zig: None,
                 default_target: None,
                 pre_build: None,
                 dockerfile: None,
diff --git a/src/docker/custom.rs b/src/docker/custom.rs
index 5079de5ea..cf378320d 100644
--- a/src/docker/custom.rs
+++ b/src/docker/custom.rs
@@ -33,6 +33,7 @@ impl<'a> Dockerfile<'a> {
         build_args: impl IntoIterator<Item = (impl AsRef<str>, impl AsRef<str>)>,
         target_triple: &Target,
         msg_info: MessageInfo,
+        uses_zig: bool,
     ) -> Result<String> {
         let mut docker_build = docker::subcommand(engine, "build");
         docker_build.current_dir(host_root);
@@ -81,7 +82,7 @@ impl<'a> Dockerfile<'a> {
         };
 
         if matches!(self, Dockerfile::File { .. }) {
-            if let Ok(cross_base_image) = self::image_name(config, target_triple) {
+            if let Ok(cross_base_image) = self::image_name(config, target_triple, uses_zig) {
                 docker_build.args([
                     "--build-arg",
                     &format!("CROSS_BASE_IMAGE={cross_base_image}"),
diff --git a/src/docker/local.rs b/src/docker/local.rs
index d88f191b9..f5d4ef879 100644
--- a/src/docker/local.rs
+++ b/src/docker/local.rs
@@ -9,7 +9,7 @@ use crate::errors::Result;
 use crate::extensions::CommandExt;
 use crate::file::{PathExt, ToUtf8};
 use crate::shell::{MessageInfo, Stream};
-use crate::{Config, Target};
+use crate::{CargoVariant, Config, Target};
 use eyre::Context;
 
 #[allow(clippy::too_many_arguments)] // TODO: refactor
@@ -19,7 +19,7 @@ pub(crate) fn run(
     args: &[String],
     metadata: &CargoMetadata,
     config: &Config,
-    uses_xargo: bool,
+    cargo_variant: CargoVariant,
     sysroot: &Path,
     msg_info: MessageInfo,
     docker_in_docker: bool,
@@ -27,12 +27,18 @@ pub(crate) fn run(
 ) -> Result<ExitStatus> {
     let dirs = Directories::create(engine, metadata, cwd, sysroot, docker_in_docker)?;
 
-    let mut cmd = cargo_safe_command(uses_xargo);
+    let mut cmd = cargo_safe_command(cargo_variant);
     cmd.args(args);
 
     let mut docker = subcommand(engine, "run");
     docker.args(&["--userns", "host"]);
-    docker_envvars(&mut docker, config, target, msg_info)?;
+    docker_envvars(
+        &mut docker,
+        config,
+        target,
+        cargo_variant.uses_zig(),
+        msg_info,
+    )?;
 
     let mount_volumes = docker_mount(
         &mut docker,
@@ -83,10 +89,18 @@ pub(crate) fn run(
             docker.arg("-t");
         }
     }
-    let mut image = image_name(config, target)?;
+    let mut image = image_name(config, target, cargo_variant.uses_zig())?;
     if needs_custom_image(target, config) {
-        image = custom_image_build(target, config, metadata, dirs, engine, msg_info)
-            .wrap_err("when building custom image")?
+        image = custom_image_build(
+            target,
+            config,
+            metadata,
+            dirs,
+            engine,
+            msg_info,
+            cargo_variant.uses_zig(),
+        )
+        .wrap_err("when building custom image")?
     }
 
     docker
diff --git a/src/docker/mod.rs b/src/docker/mod.rs
index f2c9da22e..17342a56a 100644
--- a/src/docker/mod.rs
+++ b/src/docker/mod.rs
@@ -13,7 +13,7 @@ use std::process::ExitStatus;
 use crate::cargo::CargoMetadata;
 use crate::errors::*;
 use crate::shell::MessageInfo;
-use crate::{Config, Target};
+use crate::{CargoVariant, Config, Target};
 
 #[allow(clippy::too_many_arguments)] // TODO: refactor
 pub fn run(
@@ -22,7 +22,7 @@ pub fn run(
     args: &[String],
     metadata: &CargoMetadata,
     config: &Config,
-    uses_xargo: bool,
+    cargo_variant: CargoVariant,
     sysroot: &Path,
     msg_info: MessageInfo,
     docker_in_docker: bool,
@@ -35,7 +35,7 @@ pub fn run(
             args,
             metadata,
             config,
-            uses_xargo,
+            cargo_variant,
             sysroot,
             msg_info,
             docker_in_docker,
@@ -49,7 +49,7 @@ pub fn run(
             args,
             metadata,
             config,
-            uses_xargo,
+            cargo_variant,
             sysroot,
             msg_info,
             docker_in_docker,
diff --git a/src/docker/remote.rs b/src/docker/remote.rs
index 4e49ea0b5..5f55aea08 100644
--- a/src/docker/remote.rs
+++ b/src/docker/remote.rs
@@ -17,7 +17,7 @@ use crate::rustc::{self, VersionMetaExt};
 use crate::rustup;
 use crate::shell::{self, MessageInfo, Stream};
 use crate::temp;
-use crate::{Host, Target};
+use crate::{CargoVariant, Host, Target};
 
 // the mount directory for the data volume.
 pub const MOUNT_PREFIX: &str = "/cross";
@@ -758,7 +758,7 @@ pub(crate) fn run(
     args: &[String],
     metadata: &CargoMetadata,
     config: &Config,
-    uses_xargo: bool,
+    cargo_variant: CargoVariant,
     sysroot: &Path,
     msg_info: MessageInfo,
     docker_in_docker: bool,
@@ -818,7 +818,13 @@ pub(crate) fn run(
     docker.args(&["--userns", "host"]);
     docker.args(&["--name", &container]);
     docker.args(&["-v", &format!("{}:{mount_prefix}", volume.as_ref())]);
-    docker_envvars(&mut docker, config, target, msg_info)?;
+    docker_envvars(
+        &mut docker,
+        config,
+        target,
+        cargo_variant.uses_zig(),
+        msg_info,
+    )?;
 
     let mut volumes = vec![];
     let mount_volumes = docker_mount(
@@ -851,7 +857,7 @@ pub(crate) fn run(
     }
 
     docker
-        .arg(&image_name(config, target)?)
+        .arg(&image_name(config, target, cargo_variant.uses_zig())?)
         // ensure the process never exits until we stop it
         .args(&["sh", "-c", "sleep infinity"])
         .run_and_get_status(msg_info, true)?;
@@ -1000,7 +1006,7 @@ pub(crate) fn run(
         final_args.push("--target-dir".to_string());
         final_args.push(target_dir_string);
     }
-    let mut cmd = cargo_safe_command(uses_xargo);
+    let mut cmd = cargo_safe_command(cargo_variant);
     cmd.args(final_args);
 
     // 5. create symlinks for copied data
diff --git a/src/docker/shared.rs b/src/docker/shared.rs
index 9a4b0afc3..3899ad3c6 100644
--- a/src/docker/shared.rs
+++ b/src/docker/shared.rs
@@ -13,7 +13,7 @@ use crate::file::{self, write_file, PathExt, ToUtf8};
 use crate::id;
 use crate::rustc::{self, VersionMetaExt};
 use crate::shell::{self, MessageInfo, Verbosity};
-use crate::Target;
+use crate::{CargoVariant, Target};
 
 pub use super::custom::CROSS_CUSTOM_DOCKERFILE_IMAGE_PREFIX;
 
@@ -212,12 +212,8 @@ pub fn parse_docker_opts(value: &str) -> Result<Vec<String>> {
     shell_words::split(value).wrap_err_with(|| format!("could not parse docker opts of {}", value))
 }
 
-pub(crate) fn cargo_safe_command(uses_xargo: bool) -> SafeCommand {
-    if uses_xargo {
-        SafeCommand::new("xargo")
-    } else {
-        SafeCommand::new("cargo")
-    }
+pub(crate) fn cargo_safe_command(cargo_variant: CargoVariant) -> SafeCommand {
+    SafeCommand::new(cargo_variant.to_str())
 }
 
 fn add_cargo_configuration_envvars(docker: &mut Command) {
@@ -268,6 +264,7 @@ pub(crate) fn docker_envvars(
     docker: &mut Command,
     config: &Config,
     target: &Target,
+    uses_zig: bool,
     msg_info: MessageInfo,
 ) -> Result<()> {
     for ref var in config.env_passthrough(target)?.unwrap_or_default() {
@@ -286,6 +283,10 @@ pub(crate) fn docker_envvars(
         .args(&["-e", "CARGO_HOME=/cargo"])
         .args(&["-e", "CARGO_TARGET_DIR=/target"])
         .args(&["-e", &cross_runner]);
+    if uses_zig {
+        // otherwise, zig have a permissions error trying to create the cache
+        docker.args(&["-e", "XDG_CACHE_HOME=/target/.zig-cache"]);
+    }
     add_cargo_configuration_envvars(docker);
 
     if let Some(username) = id::username().unwrap() {
@@ -467,8 +468,9 @@ pub(crate) fn custom_image_build(
     Directories { host_root, .. }: Directories,
     engine: &Engine,
     msg_info: MessageInfo,
+    uses_zig: bool,
 ) -> Result<String> {
-    let mut image = image_name(config, target)?;
+    let mut image = image_name(config, target, uses_zig)?;
 
     if let Some(path) = config.dockerfile(target)? {
         let context = config.dockerfile_context(target)?;
@@ -489,6 +491,7 @@ pub(crate) fn custom_image_build(
                 config.dockerfile_build_args(target)?.unwrap_or_default(),
                 target,
                 msg_info,
+                uses_zig,
             )
             .wrap_err("when building dockerfile")?;
     }
@@ -514,6 +517,7 @@ pub(crate) fn custom_image_build(
                     Some(("CROSS_CMD", pre_build.join("\n"))),
                     target,
                     msg_info,
+                    uses_zig,
                 )
                 .wrap_err("when pre-building")
                 .with_note(|| format!("CROSS_CMD={}", pre_build.join("\n")))?;
@@ -523,7 +527,7 @@ pub(crate) fn custom_image_build(
     Ok(image)
 }
 
-pub(crate) fn image_name(config: &Config, target: &Target) -> Result<String> {
+pub(crate) fn image_name(config: &Config, target: &Target, uses_zig: bool) -> Result<String> {
     if let Some(image) = config.image(target)? {
         return Ok(image);
     }
@@ -541,7 +545,16 @@ pub(crate) fn image_name(config: &Config, target: &Target) -> Result<String> {
         "main"
     };
 
-    Ok(format!("{CROSS_IMAGE}/{target}:{version}"))
+    let name = if uses_zig {
+        if let Some(image) = config.zig_image(target)? {
+            return Ok(image);
+        }
+        "zig"
+    } else {
+        target.triple()
+    };
+
+    Ok(format!("{CROSS_IMAGE}/{name}:{version}"))
 }
 
 fn docker_read_mount_paths(engine: &Engine) -> Result<Vec<MountDetail>> {
diff --git a/src/lib.rs b/src/lib.rs
index 038d674cf..21152c0b1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -317,11 +317,11 @@ impl std::fmt::Display for Target {
 }
 
 impl Target {
-    pub fn from(triple: &str, target_list: &TargetList) -> Target {
-        if target_list.contains(triple) {
-            Target::new_built_in(triple)
+    pub fn from(target: &str, target_list: &TargetList) -> Target {
+        if target_list.contains(target) {
+            Target::new_built_in(target)
         } else {
-            Target::new_custom(triple)
+            Target::new_custom(target)
         }
     }
 }
@@ -353,11 +353,42 @@ impl From<String> for Target {
 
 impl Serialize for Target {
     fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CargoVariant {
+    Cargo,
+    Xargo,
+    Zig,
+}
+
+impl CargoVariant {
+    pub fn create(uses_zig: bool, uses_xargo: bool) -> Result<CargoVariant> {
+        match (uses_zig, uses_xargo) {
+            (true, true) => eyre::bail!("cannot use both zig and xargo"),
+            (true, false) => Ok(CargoVariant::Zig),
+            (false, true) => Ok(CargoVariant::Xargo),
+            (false, false) => Ok(CargoVariant::Cargo),
+        }
+    }
+
+    pub fn to_str(self) -> &'static str {
         match self {
-            Target::BuiltIn { triple } => serializer.serialize_str(triple),
-            Target::Custom { triple } => serializer.serialize_str(triple),
+            CargoVariant::Cargo => "cargo",
+            CargoVariant::Xargo => "xargo",
+            CargoVariant::Zig => "cargo-zigbuild",
         }
     }
+
+    pub fn uses_xargo(self) -> bool {
+        self == CargoVariant::Xargo
+    }
+
+    pub fn uses_zig(self) -> bool {
+        self == CargoVariant::Zig
+    }
 }
 
 pub fn run() -> Result<ExitStatus> {
@@ -387,7 +418,9 @@ pub fn run() -> Result<ExitStatus> {
             .unwrap_or_else(|| Target::from(host.triple(), &target_list));
         config.confusable_target(&target, args.msg_info)?;
 
-        let image_exists = match docker::image_name(&config, &target) {
+        let uses_zig = config.zig(&target).unwrap_or(false);
+        let zig_version = config.zig_version(&target)?;
+        let image_exists = match docker::image_name(&config, &target, uses_zig) {
             Ok(_) => true,
             Err(err) => {
                 shell::warn(err, args.msg_info)?;
@@ -420,6 +453,7 @@ pub fn run() -> Result<ExitStatus> {
             let uses_build_std = config.build_std(&target).unwrap_or(false);
             let uses_xargo =
                 !uses_build_std && config.xargo(&target).unwrap_or(!target.is_builtin());
+            let cargo_variant = CargoVariant::create(uses_zig, uses_xargo)?;
             if std::env::var("CROSS_CUSTOM_TOOLCHAIN").is_err() {
                 // build-std overrides xargo, but only use it if it's a built-in
                 // tool but not an available target or doesn't have rust-std.
@@ -475,7 +509,12 @@ pub fn run() -> Result<ExitStatus> {
             } else if !args.all.iter().any(|a| a.starts_with("--target")) {
                 let mut args_with_target = args.all.clone();
                 args_with_target.push("--target".to_string());
-                args_with_target.push(target.triple().to_string());
+                let mut target_and_libc = target.triple().to_string();
+                if let Some(libc) = zig_version {
+                    target_and_libc.push('.');
+                    target_and_libc.push_str(&libc);
+                }
+                args_with_target.push(target_and_libc);
                 args_with_target
             } else {
                 args.all.clone()
@@ -513,7 +552,7 @@ pub fn run() -> Result<ExitStatus> {
                     &filtered_args,
                     &metadata,
                     &config,
-                    uses_xargo,
+                    cargo_variant,
                     &sysroot,
                     args.msg_info,
                     args.docker_in_docker,
diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs
index d32b2c02f..5bfdb72d7 100644
--- a/xtask/src/ci.rs
+++ b/xtask/src/ci.rs
@@ -69,6 +69,11 @@ pub fn ci(args: CiJob, metadata: CargoMetadata) -> cross::Result<()> {
             if target.has_ci_image() {
                 gha_output("has-image", "true")
             }
+            if target.is_default_test_image() {
+                gha_output("test-variant", "default")
+            } else {
+                gha_output("test-variant", &target.triplet)
+            }
         }
         CiJob::Check { ref_type, ref_name } => {
             let version = semver::Version::parse(&cross_meta.version)?;
diff --git a/xtask/src/util.rs b/xtask/src/util.rs
index 8eafeb3a0..f70f43618 100644
--- a/xtask/src/util.rs
+++ b/xtask/src/util.rs
@@ -129,6 +129,11 @@ impl ImageTarget {
             .iter()
             .any(|m| m.builds_image() && m.target == self.triplet && m.sub == self.sub)
     }
+
+    /// Determine if this target uses the default test script
+    pub fn is_default_test_image(&self) -> bool {
+        self.triplet != "zig"
+    }
 }
 
 impl std::str::FromStr for ImageTarget {