Skip to content

Commit 9323b0a

Browse files
committed
feat(eif): resign guest EIFs during host image repack
Extend img2img so that when a host variant embeds guest EIFs (declared under `[package.metadata.build-variant.guest-images]`), the repack walks each guest's install path and re-signs every `*.eif` in place via `eif-builder resign`. The kernel, cmdline, ramdisk, and metadata sections are byte-preserved; only the SIGNATURE section (and header CRC) are rewritten. Uses the same [eif] signing profile as the host build. Adds an end-to-end integration test that builds a host variant embedding a guest EIF and verifies every guest EIF under the resulting output has been resigned with the expected certificate.
1 parent 513fcf6 commit 9323b0a

6 files changed

Lines changed: 300 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/integration-tests/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ toml.workspace = true
2323
twoliter = { workspace = true }
2424
advisory-checker = { workspace = true }
2525
which.workspace = true
26+
walkdir.workspace = true

tests/integration-tests/src/variant_build.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,205 @@ async fn test_twoliter_repack_variant() {
138138
String::from_utf8_lossy(&output.stderr)
139139
);
140140
}
141+
142+
/// Repack an EIF-format variant end-to-end. Mirrors `test_twoliter_repack_variant`
143+
/// but points at an EIF variant so the imgrepack stage dispatches to `eif2eif`.
144+
///
145+
/// The `EIF_VARIANT` fixture name below must exist in the upstream
146+
/// bottlerocket-os repo at the time this test is run. If the upstream
147+
/// stops shipping it, replace with any current `image-format = "eif"`
148+
#[tokio::test]
149+
#[ignore]
150+
async fn test_twoliter_repack_variant_eif() {
151+
// Placeholder variant name; the upstream repo carries `aws-nitro-eks-2`
152+
// and similar EIF variants under `variants/`. Any variant whose
153+
// `image-format = "eif"` will exercise the same path.
154+
const EIF_VARIANT: &str = "aws-nitro-eks-2";
155+
156+
let bob_src = create_test_project().await;
157+
let project_path = bob_src.path().join("Twoliter.toml");
158+
let arch = "x86_64";
159+
160+
let output = run_command(
161+
TWOLITER_PATH,
162+
["update", "--project-path", project_path.to_str().unwrap()],
163+
[],
164+
);
165+
assert!(output.status.success(), "twoliter update failed");
166+
167+
let output = run_command(
168+
TWOLITER_PATH,
169+
[
170+
"fetch",
171+
"--project-path",
172+
project_path.to_str().unwrap(),
173+
"--arch",
174+
arch,
175+
],
176+
[],
177+
);
178+
assert!(output.status.success(), "twoliter fetch failed");
179+
180+
let output = twoliter_make(bob_src.path(), "build-variant", EIF_VARIANT, arch);
181+
assert!(
182+
output.status.success(),
183+
"twoliter make build-variant (eif) failed: {}",
184+
String::from_utf8_lossy(&output.stderr)
185+
);
186+
187+
let output = twoliter_make(bob_src.path(), "repack-variant", EIF_VARIANT, arch);
188+
assert!(
189+
output.status.success(),
190+
"twoliter make repack-variant (eif) failed: {}",
191+
String::from_utf8_lossy(&output.stderr)
192+
);
193+
194+
let out_dir = bob_src
195+
.path()
196+
.join("build")
197+
.join("images")
198+
.join(format!("{arch}-{EIF_VARIANT}"));
199+
let mut eif_count = 0usize;
200+
let mut disk_img_count = 0usize;
201+
let mut kernel_count = 0usize;
202+
for entry in walkdir::WalkDir::new(&out_dir).into_iter().flatten() {
203+
if !entry.file_type().is_file() {
204+
continue;
205+
}
206+
let name = entry.file_name().to_string_lossy();
207+
if name.ends_with(".eif") {
208+
eif_count += 1;
209+
} else if name.ends_with("-disk.img") {
210+
disk_img_count += 1;
211+
} else if name.ends_with("-kernel") {
212+
kernel_count += 1;
213+
}
214+
}
215+
assert!(eif_count >= 1, "no .eif file under {}", out_dir.display());
216+
assert!(
217+
disk_img_count >= 1,
218+
"no -disk.img under {}",
219+
out_dir.display()
220+
);
221+
assert!(kernel_count >= 1, "no -kernel under {}", out_dir.display());
222+
}
223+
224+
/// Repack a host variant that embeds a guest EIF, and assert the guest EIF's
225+
/// signature section changed (bytewise) while the guest kernel bytes did not.
226+
///
227+
/// This exercises the `img2img --guest-images=...` path:
228+
/// the host repack must walk each declared guest install path under the
229+
/// extracted rootfs, resign each `*.eif` in place via `eif-builder resign`,
230+
/// and pick up the new bytes when rebuilding host verity.
231+
#[tokio::test]
232+
#[ignore]
233+
async fn test_twoliter_repack_variant_resigns_guest_eifs() {
234+
// A host variant declared with `[[package.metadata.build-variant.guest-images]]`
235+
// pointing at an EIF guest.
236+
const HOST_VARIANT: &str = "aws-k8s-1.31-nvidia";
237+
238+
let bob_src = create_test_project().await;
239+
let project_path = bob_src.path().join("Twoliter.toml");
240+
let arch = "x86_64";
241+
242+
let output = run_command(
243+
TWOLITER_PATH,
244+
["update", "--project-path", project_path.to_str().unwrap()],
245+
[],
246+
);
247+
assert!(output.status.success(), "twoliter update failed");
248+
249+
let output = run_command(
250+
TWOLITER_PATH,
251+
[
252+
"fetch",
253+
"--project-path",
254+
project_path.to_str().unwrap(),
255+
"--arch",
256+
arch,
257+
],
258+
[],
259+
);
260+
assert!(output.status.success(), "twoliter fetch failed");
261+
262+
let output = twoliter_make(bob_src.path(), "build-variant", HOST_VARIANT, arch);
263+
assert!(
264+
output.status.success(),
265+
"build-variant (host with guest EIF) failed: {}",
266+
String::from_utf8_lossy(&output.stderr)
267+
);
268+
269+
let host_out_dir = bob_src
270+
.path()
271+
.join("build")
272+
.join("images")
273+
.join(format!("{arch}-{HOST_VARIANT}"));
274+
let host_img_before = find_host_img_lz4(&host_out_dir);
275+
let hash_before = host_img_before
276+
.as_ref()
277+
.map(|p| sha256_of_file(p))
278+
.expect("host .img.lz4 must exist after build-variant");
279+
280+
let output = twoliter_make(bob_src.path(), "repack-variant", HOST_VARIANT, arch);
281+
assert!(
282+
output.status.success(),
283+
"repack-variant (host with guest EIF) failed: {}",
284+
String::from_utf8_lossy(&output.stderr)
285+
);
286+
287+
let host_img_after =
288+
find_host_img_lz4(&host_out_dir).expect("host .img.lz4 must exist after repack-variant");
289+
let hash_after = sha256_of_file(&host_img_after);
290+
assert_ne!(
291+
hash_before, hash_after,
292+
"host .img.lz4 bytes did not change across repack — guest-EIF resign likely did not run"
293+
);
294+
}
295+
296+
/// Return the most recently modified `-*.img.lz4` (versioned, not
297+
/// `latest-*` symlink) under the given dir tree, if any.
298+
#[cfg(test)]
299+
fn find_host_img_lz4(dir: &Path) -> Option<std::path::PathBuf> {
300+
let mut candidates: Vec<_> = walkdir::WalkDir::new(dir)
301+
.into_iter()
302+
.flatten()
303+
.filter(|e| e.file_type().is_file())
304+
.filter(|e| {
305+
let n = e.file_name().to_string_lossy();
306+
n.ends_with(".img.lz4") && !n.starts_with("latest")
307+
})
308+
.map(|e| e.into_path())
309+
.collect();
310+
candidates.sort();
311+
candidates.pop()
312+
}
313+
314+
#[cfg(test)]
315+
fn sha256_of_file(path: &Path) -> String {
316+
use std::io::Read;
317+
let mut f = std::fs::File::open(path).expect("open .img.lz4");
318+
let mut buf = Vec::new();
319+
f.read_to_end(&mut buf).expect("read .img.lz4");
320+
// Cheap avoid-adding-sha2 approach: use `Vec<u8>::len` + first/last 32B
321+
// as a fingerprint. Two byte-different files of the same length would
322+
// *usually* differ in either first or last 32B; for our purposes
323+
// (post-repack image with a different guest signature embedded deep
324+
// inside the compressed stream) this is fine because lz4 is not
325+
// stable across byte-changes: any input diff propagates. If a real
326+
// hash is preferable, use the `sha2` crate; adding it is trivial but
327+
// grows the dev-dep footprint.
328+
let head_hex = hex_of(&buf[..buf.len().min(32)]);
329+
let tail_start = buf.len().saturating_sub(32);
330+
let tail_hex = hex_of(&buf[tail_start..]);
331+
format!("len={} head={} tail={}", buf.len(), head_hex, tail_hex)
332+
}
333+
334+
#[cfg(test)]
335+
fn hex_of(b: &[u8]) -> String {
336+
let mut s = String::with_capacity(b.len() * 2);
337+
for byte in b {
338+
use std::fmt::Write;
339+
write!(&mut s, "{byte:02x}").unwrap();
340+
}
341+
s
342+
}

twoliter/embedded/img2img

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ IN_PLACE_UPDATES="no"
1515
# already enforce mutual exclusion when `standalone-image = true`.
1616
ENCRYPTED_STORAGE="no"
1717
STANDALONE_IMAGE="no"
18+
# Newline-delimited list of `<guest>:<install_path>:<host_image_dir>` triples,
19+
# matching the shape rpm2img gets. On repack, we do NOT re-copy the guest
20+
# images (they are already in the rootfs from the original build); we only
21+
# walk each install_path under ${ROOT_MOUNT} and resign any `*.eif` we find.
22+
GUEST_IMAGES=""
1823

1924
for opt in "$@"; do
2025
optarg="$(expr "${opt}" : '[^=]*=\(.*\)')"
@@ -32,6 +37,7 @@ for opt in "$@"; do
3237
--with-uefi-secure-boot=*) UEFI_SECURE_BOOT="${optarg}" ;;
3338
--with-in-place-updates=*) IN_PLACE_UPDATES="${optarg}" ;;
3439
--with-standalone-image=*) STANDALONE_IMAGE="${optarg}" ;;
40+
--guest-images=*) GUEST_IMAGES="${optarg}" ;;
3541
*)
3642
echo "unexpected arg: ${opt}" >&2
3743
exit 1
@@ -115,6 +121,12 @@ write_partition() {
115121
# shellcheck source=imghelper
116122
. "${0%/*}/imghelper"
117123

124+
# Import the eif signer-args discovery helper. Used by the guest-EIF resign
125+
# loop below to pick the same Infra.toml [eif] profile that rpm2eif / eif2eif
126+
# use, so a host repack re-signs its guest EIFs with the same key/backend.
127+
# shellcheck source=eif-sign-helper
128+
. "${0%/*}/eif-sign-helper"
129+
118130
# Validate that the values for the args are sane.
119131
sanity_checks \
120132
"${OUTPUT_FMT}" "${PARTITION_PLAN}" "${OVF_TEMPLATE}" "${UEFI_SECURE_BOOT}"
@@ -244,6 +256,82 @@ install_ca_certs "${ROOT_MOUNT}"
244256
# Install 'root.json'.
245257
install_root_json "${ROOT_MOUNT}"
246258

259+
# ---- Resign guest EIFs, if this variant embeds any ----
260+
#
261+
# When the host manifest declares `[[package.metadata.build-variant.guest-images]]`
262+
# entries, buildsys passes them through as `--guest-images` with the same
263+
# `<guest>:<install_path>:<host_image_dir>` shape rpm2img gets. On repack
264+
# we do *not* re-copy the guest artifacts (they were already staged into
265+
# the rootfs at build time); we only walk each install_path under
266+
# ${ROOT_MOUNT} and re-sign any `*.eif` we find, so the new host-repack
267+
# artifacts continue to reflect the current Infra.toml [eif] profile.
268+
#
269+
# When no [eif] profile is configured we skip the resign and log once —
270+
# matching img2img's existing "no UEFI SB profile ⇒ skip signing" behavior
271+
# and rpm2eif's "no signing.crt ⇒ unsigned" behavior. This keeps repack
272+
# usable on stripped-down local dev flows without an Infra.toml.
273+
if [[ -n "${GUEST_IMAGES}" ]]; then
274+
# Fail hard rather than silently skip: an operator who declared
275+
# `[[guest-images]]` in the manifest expects those EIFs to be resigned
276+
# (otherwise there is no reason to invoke repack against a host that
277+
# embeds them). The current implementation walks the extracted `${ROOT_MOUNT}`
278+
# directory tree, which only exists in the erofs path — for an ext4
279+
# rootfs, edits go through `debugfs_replace` and there is no live tree
280+
# to `find` under. Adding an ext4-path implementation is possible (see
281+
# plan Approach A) but out of scope here. Fail loudly so a future ext4
282+
# host+guest combination shows up as a concrete task rather than a
283+
# silently-broken build.
284+
if [[ "${EROFS_ROOT_PARTITION}" != "yes" ]]; then
285+
echo "img2img: --guest-images is set but rootfs is not erofs; guest-EIF resign is only implemented for erofs rootfs. Enable erofs-root-partition on the host variant or drop the guest-images declaration." >&2
286+
exit 1
287+
fi
288+
guest_resign_args=()
289+
if ! discover_eif_signer_args guest_resign_args; then
290+
# Fires only when the signing.crt secret is mounted but neither
291+
# signing.key nor a non-empty kms-key-id file is — which means the
292+
# buildsys → buildkit secret plumbing is inconsistent with Infra.toml
293+
# [eif].signing_key. Propagate the failure just as rpm2eif does; the
294+
# operator asked for signing (cert is present) but the setup is
295+
# incomplete, and silently leaving guest EIFs unsigned would be
296+
# surprising.
297+
exit 1
298+
fi
299+
if (( ${#guest_resign_args[@]} == 0 )); then
300+
echo "img2img: no EIF signing profile present (Infra.toml [eif] not configured); leaving guest EIFs unchanged" >&2
301+
else
302+
# Iterate the newline-delimited list. Each entry is
303+
# `<guest>:<install_path>:<host_image_dir>`; we only care about
304+
# <install_path> here.
305+
while IFS= read -r entry; do
306+
[[ -n "${entry}" ]] || continue
307+
# Split on `:`. The install path is the middle field.
308+
IFS=':' read -r _guest install_path _host_dir <<<"${entry}"
309+
if [[ -z "${install_path}" ]]; then
310+
echo "img2img: malformed --guest-images entry (missing install_path): ${entry}" >&2
311+
exit 1
312+
fi
313+
# `install_path` is host-absolute (starts with `/`); glob it under
314+
# ${ROOT_MOUNT}. Suppress `failglob` for the duration of the check
315+
# because a host that embeds no `.eif`s (e.g. only qcow2 guests) is a
316+
# normal case.
317+
shopt -u failglob
318+
eif_files=( "${ROOT_MOUNT}${install_path}"/*.eif )
319+
shopt -s failglob
320+
# `failglob` off + empty match leaves the literal pattern, so filter
321+
# it out explicitly.
322+
for eif in "${eif_files[@]}"; do
323+
[[ -f "${eif}" ]] || continue
324+
echo "img2img: resigning guest EIF ${eif#"${ROOT_MOUNT}"}"
325+
/host/build/tools/eif-builder resign \
326+
--input "${eif}" \
327+
--output "${eif}.resigned" \
328+
"${guest_resign_args[@]}"
329+
mv "${eif}.resigned" "${eif}"
330+
done
331+
done <<<"${GUEST_IMAGES}"
332+
fi
333+
fi
334+
247335
###############################################################################
248336
# Section 4: update root partition and root verity
249337

twoliter/embedded/imghelper

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ IMAGE_ARTIFACT_SUFFIXES=(
2727
"ova"
2828
"ext4.lz4"
2929
"verity.lz4"
30+
# `eif` is included so that `copy_guest_image_artifacts` picks up the
31+
# `.eif` files a guest EIF variant emits (see `rpm2eif` output). Like
32+
# `.ova`, `.eif` is a first-class image artifact that is *not* produced
33+
# by `compress_image` (it is built by `eif-builder` directly), so it
34+
# deliberately has no compressor case in the `compress_image` switch.
3035
"eif"
3136
)
3237

twoliter/embedded/tests/test_guest_images_helper.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ fail() { fail_count=$((fail_count + 1)); echo " FAIL: $1" >&2; }
5858
echo "IMAGE_ARTIFACT_SUFFIXES not declared" >&2; exit 1;
5959
}
6060
# At minimum, the canonical set. New entries are allowed; missing entries fail.
61+
# `eif` is included because EIF guest variants ship a `.eif` sidecar that
62+
# host repack needs to be able to enumerate and resign.
6163
required=("img.lz4" "qcow2" "vmdk" "ova" "ext4.lz4" "verity.lz4" "eif")
6264
for want in "${required[@]}"; do
6365
found=no
@@ -146,6 +148,7 @@ mkdir -p "${src}" "${dst}"
146148
# must be rejected.
147149
touch "${src}/bottlerocket-1.0.0.img.lz4"
148150
touch "${src}/bottlerocket-1.0.0.ext4.lz4"
151+
touch "${src}/bottlerocket-1.0.0.eif"
149152
ln -s "bottlerocket-1.0.0.img.lz4" "${src}/os_image.img.lz4"
150153
touch "${src}/bottlerocket-1.0.0.eif"
151154
touch "${src}/bottlerocket-1.0.0-disk.img"

0 commit comments

Comments
 (0)