//! # NoCloud cloud-init seed — draupnir's owned, pure seed builder
//!
//! The **NoCloud** datasource is how cloud-init provisions a machine at first boot
//! from a **local** source (no metadata server): it looks for a volume — a vfat
//! image or a directory — labelled `cidata` carrying a small file set
//! (`user-data`, `meta-data`, and an optional `network-config`) and applies it.
//!
//! This module is the **pure builder** that emits that seed. It is the *source of
//! truth* for the NoCloud seed across the constellation — ported from
//! `Skidbladnir/src/machine.rs`'s `KvmController::nocloud_seed_files` /
//! `build_seed_image` / `build_fat_image` so draupnir (the one boot engine) owns
//! it, and Skidbladnir/jera consume it rather than re-rolling their own.
//!
//! ## Two outputs, one file set
//!
//! - [`seed_files`] — the **pure** render of the NoCloud file set from a
//! [`CloudInit`]. Zero dependencies, always compiled. Everything else is built
//! on top of it, so the *contents* of a seed are identical whichever container
//! carries them.
//! - [`write_seed_dir`] — write the file set into a plain **seed directory** (a
//! NoCloud `seedfrom` dir / a bind-mount / a `-fw_cfg` source). Pure std, always
//! compiled — the always-works fallback that needs no image tooling.
//! - [`build_seed_image`] — author a **vfat seed image** (volume label `cidata`)
//! in **pure Rust** via `fatfs` — no `genisoimage`/`mkisofs` subprocess. Behind
//! the `seed` feature (the only part that pulls a dependency); this is what the
//! KVM backend attaches to a VM as a read-only virtio drive.
//!
//! Only the seed *image* costs a dependency; the file-set and the seed *directory*
//! are pure std, so any boot path (KVM today; a container init that reads a NoCloud
//! dir tomorrow) can attach a seed with no extra crates.
use crate::{CloudInit, Error, Result};
use std::path::{Path, PathBuf};
/// The canonical NoCloud **volume label**. cloud-init matches it
/// case-insensitively; `cidata` is the canonical spelling. It labels both the vfat
/// [`build_seed_image`] output and (conventionally) a [`write_seed_dir`] mount.
pub const NOCLOUD_LABEL: &str = "cidata";
/// The default `meta-data` supplied when a [`CloudInit`] omits one — a minimal
/// document carrying an `instance-id` and `local-hostname` so cloud-init always
/// finds the two files a NoCloud datasource requires.
pub const DEFAULT_META_DATA: &str = "instance-id: draupnir\nlocal-hostname: draupnir\n";
/// **Pure** render of the NoCloud seed file set — `[("user-data", …),
/// ("meta-data", …)]`, plus `("network-config", …)` when the [`CloudInit`] carries
/// one. When `meta-data` is omitted, [`DEFAULT_META_DATA`] is supplied so both
/// required files are always present. This is the testable core every other output
/// in this module is built from (zero deps, always compiled).
pub fn seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
let md = ci
.meta_data
.clone()
.unwrap_or_else(|| DEFAULT_META_DATA.to_string());
let mut files = vec![("user-data", ci.user_data.clone()), ("meta-data", md)];
// cloud-init reads an optional `network-config` from the same NoCloud volume.
if let Some(nc) = &ci.network_config {
files.push(("network-config", nc.clone()));
}
files
}
/// Write the NoCloud [`seed_files`] into `dir` as a plain **seed directory** (the
/// pure-std, no-image-tooling output): `dir/user-data`, `dir/meta-data`, and
/// `dir/network-config` when present. `dir` is created if it does not exist.
/// Returns the directory path. Always compiled (no feature, no dependency).
pub fn write_seed_dir(dir: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
let dir = dir.as_ref();
let outcome = write_seed_dir_inner(dir, ci);
// Record the NoCloud-seed verdict: GREEN when the file set landed on disk, RED
// when a mkdir/write failed — the seed surface nornir's matrix reads back (a boot
// whose cloud-init seed could not be authored must be visible, never swallowed).
crate::functional_status(
"draupnir/seed",
"write_seed_dir",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!("NoCloud seed dir written to {}", dir.display()),
Err(e) => format!("NoCloud seed dir {} failed: {e}", dir.display()),
},
);
outcome.map(|()| dir.to_path_buf())
}
/// Create `dir` (and any missing parents) with the leaf **private to its owner**
/// — mode 0700 on unix, applied by `mkdir(2)` itself so there is never an
/// instant at which the directory is group- or world-readable.
///
/// A directory that ALREADY exists is left exactly as it is, deliberately: the
/// parent of a seed may be `/tmp` or a caller-owned tree, and silently
/// tightening someone else's directory is a worse bug than the one this fixes.
/// The mode that protects the key material is the FILE mode below; this is
/// defence in depth for the directory this function itself creates.
///
/// Public because a CONSUMER that pre-creates the workdir it later hands to
/// `build_seed_image*` must create it with the same discipline — draupnir owns
/// the seed writer, so it owns this too rather than every caller re-rolling it.
pub fn create_private_dir(dir: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir)
.map_err(|e| Error::Backend(format!("mkdir {}: {e}", dir.display())))
}
}
/// Write `bytes` to `path` in a file **created** mode 0600 (unix).
///
/// The mode is handed to `open(2)` as the creation mode, not applied afterwards
/// with `set_permissions`: a `write`-then-`chmod` leaves a window in which the
/// key material is already on disk and still world-readable, and that window IS
/// the defect (F61). Any pre-existing file is removed first and the create is
/// `O_EXCL`, so the mode always applies (an `open` of an existing file ignores
/// it) and a symlink planted at `path` in a shared directory makes this FAIL
/// rather than write key bytes through it.
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let _ = std::fs::remove_file(path);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts
.open(path)
.map_err(|e| Error::Backend(format!("create {}: {e}", path.display())))?;
f.write_all(bytes)
.map_err(|e| Error::Backend(format!("write {}: {e}", path.display())))?;
f.flush()
.map_err(|e| Error::Backend(format!("flush {}: {e}", path.display())))
}
/// The pure seed-directory write (no emit) — the always-works fallback path.
fn write_seed_dir_inner(dir: &Path, ci: &CloudInit) -> Result<()> {
create_private_dir(dir)?;
for (name, contents) in seed_files(ci) {
write_private(&dir.join(name), contents.as_bytes())?;
}
Ok(())
}
/// Materialize a [`CloudInit`] into a NoCloud **seed image** at `out`: a FAT12/16
/// image, volume-labelled [`NOCLOUD_LABEL`] (`cidata`), holding [`seed_files`] at
/// its root. **Pure Rust** via `fatfs` — no `genisoimage`/`mkisofs` subprocess
/// (zero-shell), the same authoring Skidbladnir uses. Returns the image path. The
/// KVM backend attaches this as a read-only virtio drive; the guest's cloud-init
/// finds it by its `cidata` vfat label at first boot. (feature `seed`)
#[cfg(feature = "seed")]
pub fn build_seed_image(out: impl AsRef<Path>, ci: &CloudInit) -> Result<PathBuf> {
let out = out.as_ref();
let blobs: Vec<(String, Vec<u8>)> = seed_files(ci)
.into_iter()
.map(|(n, c)| (n.to_string(), c.into_bytes()))
.collect();
let outcome = build_fat_image(out, NOCLOUD_LABEL, &blobs);
// Record the seed-image verdict: GREEN when the `cidata` vfat image was authored,
// RED when fatfs formatting/authoring failed — the seed surface nornir's matrix
// reads back (the KVM backend attaches this image; a failed author must be visible).
crate::functional_status(
"draupnir/seed",
"build_seed_image",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!("NoCloud `cidata` seed image authored at {}", out.display()),
Err(e) => format!("NoCloud seed image {} failed: {e}", out.display()),
},
);
outcome.map(|()| out.to_path_buf())
}
/// Materialize a [`CloudInit`] into a NoCloud **seed image** at `out` **plus a set
/// of extra, consumer-native files** at the same volume root — the knob a
/// distroless appliance needs (L5: add the knob here, never fork the builder).
///
/// The standard [`seed_files`] (`user-data`, `meta-data`, optional
/// `network-config`) are authored exactly as [`build_seed_image`] does, so a
/// full-OS guest's **cloud-init still provisions from this very image**; `extra`
/// rides alongside for guests that have no cloud-init and read their own file
/// (tunnr's distroless PID-1 reads `tunnr-appliance.conf`). An empty `extra`
/// produces a byte-identical image to [`build_seed_image`]. (feature `seed`)
#[cfg(feature = "seed")]
pub fn build_seed_image_with(
out: impl AsRef<Path>,
ci: &CloudInit,
extra: &[(String, Vec<u8>)],
) -> Result<PathBuf> {
let out = out.as_ref();
let mut blobs: Vec<(String, Vec<u8>)> = seed_files(ci)
.into_iter()
.map(|(n, c)| (n.to_string(), c.into_bytes()))
.collect();
blobs.extend(extra.iter().cloned());
let outcome = build_fat_image(out, NOCLOUD_LABEL, &blobs);
crate::functional_status(
"draupnir/seed",
"build_seed_image_with",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!(
"NoCloud `cidata` seed image (+{} extra file(s)) authored at {}",
extra.len(),
out.display()
),
Err(e) => format!("NoCloud seed image {} failed: {e}", out.display()),
},
);
outcome.map(|()| out.to_path_buf())
}
/// Read every root-level file out of an in-memory **FAT seed image** as
/// `(name, bytes)` — the GUEST side of the seam. The image is taken as a byte
/// slice (not a path) deliberately: a guest reads its seed off a **read-only**
/// virtio block device, which cannot be opened `O_RDWR` as `fatfs` requires, so
/// the caller slurps `/dev/vdX` with `std::fs::read` and hands the bytes here.
/// Pure Rust, no mount, no loop device, no `mtools`. (feature `seed`)
#[cfg(feature = "seed")]
pub fn read_seed_files(image: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
use std::io::Read;
let cursor = std::io::Cursor::new(image.to_vec());
let fs = fatfs::FileSystem::new(cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT seed image: {e}")))?;
let mut out = Vec::new();
for entry in fs.root_dir().iter() {
let entry = entry.map_err(|e| Error::Backend(format!("read FAT dir: {e}")))?;
if entry.is_dir() {
continue;
}
let name = entry.file_name();
let mut buf = Vec::new();
entry
.to_file()
.read_to_end(&mut buf)
.map_err(|e| Error::Backend(format!("read {name} from FAT seed: {e}")))?;
out.push((name, buf));
}
Ok(out)
}
/// The FAT **volume label** of an in-memory seed image — a guest uses this to
/// confirm it found the NoCloud volume ([`NOCLOUD_LABEL`], case-insensitive)
/// rather than some other attached disk. (feature `seed`)
#[cfg(feature = "seed")]
pub fn seed_volume_label(image: &[u8]) -> Result<String> {
let cursor = std::io::Cursor::new(image.to_vec());
let fs = fatfs::FileSystem::new(cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT seed image: {e}")))?;
Ok(fs.volume_label())
}
/// Whether an in-memory image is a NoCloud seed: it parses as FAT **and** its
/// volume label is [`NOCLOUD_LABEL`] (matched case-insensitively, exactly as
/// cloud-init matches it). The guest's device-probe predicate. (feature `seed`)
#[cfg(feature = "seed")]
pub fn is_nocloud_seed(image: &[u8]) -> bool {
matches!(seed_volume_label(image), Ok(l) if l.trim().eq_ignore_ascii_case(NOCLOUD_LABEL))
}
/// Read an in-memory seed image back into the [`CloudInit`] it was authored from
/// (`user-data` + `meta-data` + optional `network-config`). Round-trips
/// [`build_seed_image`]. Extra, non-standard files are ignored here — read those
/// with [`read_seed_files`]. (feature `seed`)
#[cfg(feature = "seed")]
pub fn read_seed_image(image: &[u8]) -> Result<CloudInit> {
let files = read_seed_files(image)?;
let pick = |want: &str| {
files
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(want))
.map(|(_, b)| String::from_utf8_lossy(b).into_owned())
};
let user_data =
pick("user-data").ok_or_else(|| Error::Backend("seed image has no `user-data`".into()))?;
Ok(CloudInit {
user_data,
meta_data: pick("meta-data"),
network_config: pick("network-config"),
})
}
/// Pull one **flat scalar** out of a NoCloud `meta-data` document — the
/// `key: value` lines cloud-init's NoCloud `meta-data` is made of
/// (`instance-id`, `local-hostname`). Deliberately NOT a YAML parser: a
/// distroless PID-1 must read its identity without dragging serde/yaml into a
/// static init binary, and NoCloud `meta-data` is flat by construction. Comments
/// (`#`) and surrounding quotes are stripped; the first match wins. Pure std,
/// always compiled (no feature, no dependency).
pub fn meta_data_value(meta_data: &str, key: &str) -> Option<String> {
for line in meta_data.lines() {
let line = line.split('#').next().unwrap_or("").trim();
let Some((k, v)) = line.split_once(':') else {
continue;
};
if !k.trim().eq_ignore_ascii_case(key) {
continue;
}
let v = v.trim().trim_matches(|c| c == '"' || c == '\'').trim();
if v.is_empty() {
return None;
}
return Some(v.to_string());
}
None
}
/// Author a **FAT12/16 image** at `out`, volume-labelled `label` (≤11 chars),
/// holding `files` (name → bytes) at the root — pure Rust via `fatfs`, into an
/// in-memory buffer written out in one shot. Sized to the payload + 1 MiB slack,
/// min 2 MiB, rounded up to 512 KiB. (feature `seed`)
#[cfg(feature = "seed")]
pub fn build_fat_image(out: &Path, label: &str, files: &[(String, Vec<u8>)]) -> Result<()> {
use std::io::Write;
if let Some(dir) = out.parent() {
create_private_dir(dir)?;
}
let payload: u64 = files.iter().map(|(_, b)| b.len() as u64).sum();
let needed = (payload + 1024 * 1024).max(2 * 1024 * 1024);
let size = needed.div_ceil(512 * 1024) * 512 * 1024;
let mut cursor = std::io::Cursor::new(vec![0u8; size as usize]);
let mut lbl = [b' '; 11];
for (i, b) in label.bytes().take(11).enumerate() {
lbl[i] = b;
}
fatfs::format_volume(
&mut cursor,
fatfs::FormatVolumeOptions::new().volume_label(lbl),
)
.map_err(|e| Error::Backend(format!("format FAT {}: {e}", out.display())))?;
{
let fs = fatfs::FileSystem::new(&mut cursor, fatfs::FsOptions::new())
.map_err(|e| Error::Backend(format!("open FAT {}: {e}", out.display())))?;
let root = fs.root_dir();
for (name, bytes) in files {
let mut file = root
.create_file(name)
.map_err(|e| Error::Backend(format!("create {name} in FAT: {e}")))?;
file.truncate().ok();
file.write_all(bytes)
.map_err(|e| Error::Backend(format!("write {name} in FAT: {e}")))?;
file.flush().ok();
}
}
// A seed image can carry PRIVATE KEY material (tunnr writes `PrivateKey =`
// straight into its appliance conf), so the bytes land in a file CREATED
// 0600 — never written first and chmod-ed after (F61).
write_private(out, &cursor.into_inner())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique scratch path under the system temp dir (no `tempfile` dev-dep,
/// matching the repo's existing convention).
fn scratch(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("draupnir-seed-{tag}-{}", std::process::id()))
}
/// RED-WHEN-BROKEN: a known `meta-data` + `user-data` input renders the EXACT
/// expected seed contents — the instance-id/hostname in `meta-data` and the
/// verbatim `user-data` YAML. If the render drifts, this fails on the byte.
#[test]
fn known_input_renders_exact_seed_contents() {
let user_data =
"#cloud-config\nhostname: web-01\nruncmd:\n - [systemctl, start, holger]\n";
let ci = CloudInit {
user_data: user_data.into(),
meta_data: Some("instance-id: iid-web-01\nlocal-hostname: web-01\n".into()),
network_config: None,
};
let files = seed_files(&ci);
assert_eq!(
files.len(),
2,
"just user-data + meta-data when no network-config"
);
// user-data is the verbatim YAML.
assert_eq!(files[0].0, "user-data");
assert_eq!(files[0].1, user_data);
// meta-data carries the exact instance-id + hostname.
assert_eq!(files[1].0, "meta-data");
assert_eq!(
files[1].1,
"instance-id: iid-web-01\nlocal-hostname: web-01\n"
);
}
/// An omitted `meta-data` is filled with [`DEFAULT_META_DATA`] carrying both an
/// `instance-id` and a `local-hostname` (cloud-init needs both files).
#[test]
fn omitted_meta_data_gets_the_default_instance_id_and_hostname() {
let ci = CloudInit::user_data("#cloud-config\n");
let files = seed_files(&ci);
assert_eq!(files[1].0, "meta-data");
assert_eq!(files[1].1, DEFAULT_META_DATA);
assert!(files[1].1.contains("instance-id:"));
assert!(files[1].1.contains("local-hostname:"));
}
/// A `network-config` is emitted as the third file only when present.
#[test]
fn network_config_is_the_optional_third_file() {
let plain = CloudInit::user_data("#cloud-config\n");
assert!(!seed_files(&plain)
.iter()
.any(|(n, _)| *n == "network-config"));
let net = CloudInit::user_data("#cloud-config\n")
.with_network_config("version: 2\nethernets:\n eth0:\n dhcp4: true\n");
let files = seed_files(&net);
assert_eq!(files.len(), 3);
let nc = files
.iter()
.find(|(n, _)| *n == "network-config")
.expect("network-config present");
assert_eq!(nc.1, "version: 2\nethernets:\n eth0:\n dhcp4: true\n");
}
/// The **seed directory** output writes each file with its exact contents on
/// disk (the pure-std, no-image-tooling path).
#[test]
fn write_seed_dir_writes_each_file_verbatim() {
let dir = scratch("dir");
let _ = std::fs::remove_dir_all(&dir);
let ci = CloudInit {
user_data: "#cloud-config\npackages: [curl]\n".into(),
meta_data: Some("instance-id: iid-42\nlocal-hostname: node-42\n".into()),
network_config: Some("version: 2\n".into()),
};
let out = write_seed_dir(&dir, &ci).unwrap();
assert_eq!(out, dir);
let read = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap();
assert_eq!(read("user-data"), "#cloud-config\npackages: [curl]\n");
assert_eq!(
read("meta-data"),
"instance-id: iid-42\nlocal-hostname: node-42\n"
);
assert_eq!(read("network-config"), "version: 2\n");
let _ = std::fs::remove_dir_all(&dir);
}
/// FUNCTIONAL: author the vfat seed image, read it back through `fatfs`, and
/// confirm the volume label is `cidata` and every file's contents survive the
/// round-trip (so a guest's cloud-init will find and read them). (feature `seed`)
#[cfg(feature = "seed")]
#[test]
fn build_seed_image_roundtrips_label_and_contents() {
use std::io::Read;
let out = scratch("img").with_extension("img");
let _ = std::fs::remove_file(&out);
let ci = CloudInit {
user_data: "#cloud-config\nruncmd:\n - [echo, hi]\n".into(),
meta_data: Some("instance-id: iid-img\nlocal-hostname: img-host\n".into()),
network_config: Some("version: 2\nethernets:\n eth0:\n dhcp4: true\n".into()),
};
let path = build_seed_image(&out, &ci).unwrap();
assert_eq!(path, out);
let img = std::fs::File::options()
.read(true)
.write(true)
.open(&out)
.unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
assert_eq!(fs.volume_label().to_ascii_lowercase(), NOCLOUD_LABEL);
let read = |name: &str| {
let mut buf = String::new();
fs.root_dir()
.open_file(name)
.unwrap()
.read_to_string(&mut buf)
.unwrap();
buf
};
assert_eq!(
read("user-data"),
"#cloud-config\nruncmd:\n - [echo, hi]\n"
);
assert_eq!(
read("meta-data"),
"instance-id: iid-img\nlocal-hostname: img-host\n"
);
assert_eq!(
read("network-config"),
"version: 2\nethernets:\n eth0:\n dhcp4: true\n"
);
drop(fs);
let _ = std::fs::remove_file(&out);
}
/// RED-WHEN-BROKEN, the GUEST side of the seam: an image authored by
/// [`build_seed_image`] must read back — through the byte-slice reader a
/// guest uses on a read-only virtio device — as the EXACT [`CloudInit`] it
/// was built from, and be recognised as a `cidata` NoCloud volume. If the
/// reader drifts (wrong file, truncated read, label mismatch) this fails.
/// (feature `seed`)
#[cfg(feature = "seed")]
#[test]
fn seed_image_roundtrips_through_the_guest_side_reader() {
let out = scratch("guestread").with_extension("img");
let _ = std::fs::remove_file(&out);
let ci = CloudInit {
user_data: "#cloud-config\nhostname: node-7\n".into(),
meta_data: Some("instance-id: iid-7\nlocal-hostname: node-7\n".into()),
network_config: Some("version: 2\n".into()),
};
build_seed_image(&out, &ci).unwrap();
let bytes = std::fs::read(&out).unwrap();
assert!(
is_nocloud_seed(&bytes),
"the authored image IS a `cidata` NoCloud volume"
);
assert_eq!(
seed_volume_label(&bytes).unwrap().to_ascii_lowercase(),
NOCLOUD_LABEL
);
let back = read_seed_image(&bytes).unwrap();
assert_eq!(back.user_data, ci.user_data);
assert_eq!(back.meta_data, ci.meta_data);
assert_eq!(back.network_config, ci.network_config);
let _ = std::fs::remove_file(&out);
}
/// A non-FAT blob (and a FAT volume with some *other* label) must NOT be
/// mistaken for a NoCloud seed — the guest probes several block devices and
/// must reject the ones that are not its seed. (feature `seed`)
#[cfg(feature = "seed")]
#[test]
fn a_non_cidata_image_is_not_mistaken_for_a_seed() {
assert!(
!is_nocloud_seed(&[0u8; 4096]),
"zeroes are not a NoCloud seed"
);
assert!(
!is_nocloud_seed(b"not a filesystem at all"),
"garbage is not a seed"
);
let other = scratch("otherlabel").with_extension("img");
let _ = std::fs::remove_file(&other);
build_fat_image(&other, "SOMEDISK", &[("a.txt".to_string(), b"x".to_vec())]).unwrap();
let bytes = std::fs::read(&other).unwrap();
assert!(
!is_nocloud_seed(&bytes),
"a FAT volume labelled SOMEDISK is not the `cidata` seed"
);
let _ = std::fs::remove_file(&other);
}
/// The extra-files knob: the standard NoCloud trio is still authored (a
/// full-OS guest's cloud-init still provisions) AND the consumer-native file
/// rides on the same volume for a guest that has no cloud-init.
/// (feature `seed`)
#[cfg(feature = "seed")]
#[test]
fn extra_files_ride_alongside_the_standard_nocloud_set() {
let out = scratch("extra").with_extension("img");
let _ = std::fs::remove_file(&out);
let ci = CloudInit::user_data("#cloud-config\n");
let appliance = "[Interface]\nAddress = 10.9.0.1/24\n";
build_seed_image_with(
&out,
&ci,
&[(
"tunnr-appliance.conf".to_string(),
appliance.as_bytes().to_vec(),
)],
)
.unwrap();
let bytes = std::fs::read(&out).unwrap();
let files = read_seed_files(&bytes).unwrap();
let name_of = |want: &str| files.iter().find(|(n, _)| n.eq_ignore_ascii_case(want));
assert!(
name_of("user-data").is_some(),
"cloud-init still finds user-data"
);
assert!(
name_of("meta-data").is_some(),
"cloud-init still finds meta-data"
);
let extra = name_of("tunnr-appliance.conf").expect("the consumer-native file is present");
assert_eq!(String::from_utf8_lossy(&extra.1), appliance);
// The standard CloudInit still round-trips past the extra file.
assert_eq!(
read_seed_image(&bytes).unwrap().user_data,
"#cloud-config\n"
);
let _ = std::fs::remove_file(&out);
}
/// RED-WHEN-BROKEN: the flat `meta-data` scalar reader pulls the exact
/// identity a distroless guest boots with — and returns `None` (never a
/// wrong-key value) for an absent or empty key.
#[test]
fn meta_data_value_reads_the_flat_nocloud_scalars() {
let meta = "instance-id: iid-demo-server\nlocal-hostname: tunnr-server # the name\n";
assert_eq!(
meta_data_value(meta, "instance-id").as_deref(),
Some("iid-demo-server")
);
assert_eq!(
meta_data_value(meta, "local-hostname").as_deref(),
Some("tunnr-server")
);
// case-insensitive key match (cloud-init docs use lowercase; be liberal).
assert_eq!(
meta_data_value(meta, "Local-Hostname").as_deref(),
Some("tunnr-server")
);
// absent / empty keys are None, never a neighbouring value.
assert_eq!(meta_data_value(meta, "public-keys"), None);
assert_eq!(meta_data_value("local-hostname:\n", "local-hostname"), None);
// quoted values are unwrapped.
assert_eq!(
meta_data_value("local-hostname: \"quoted-host\"\n", "local-hostname").as_deref(),
Some("quoted-host")
);
}
/// RED-WHEN-BROKEN (F61): a seed image carries the guest's **private key
/// material**, so its bytes must never be readable by another local user.
/// This asserts the mode of the file the builder CREATES — not one fixed up
/// afterwards, because the window between `write` and `chmod` is the bug.
#[cfg(all(unix, feature = "seed"))]
#[test]
fn seed_image_is_created_private_to_its_owner() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("mode");
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join("seed.img");
build_fat_image(
&out,
NOCLOUD_LABEL,
&[("secret".into(), b"PrivateKey = x".to_vec())],
)
.unwrap();
let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"seed image {} is mode {mode:o}, readable off-owner; a seed carries \
private keys and must be CREATED 0600",
out.display()
);
// The directory the builder had to create holds key-bearing images: no
// group/other bits, so a listing cannot even enumerate the fleet.
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
dmode,
0o700,
"seed directory {} is mode {dmode:o}",
dir.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
/// RED-WHEN-BROKEN (F61): the plain seed-DIRECTORY writer has the same shape
/// as the image writer — cloud-init `user-data` routinely carries passwords
/// and keys — so its files are created 0600 in a 0700 directory.
#[cfg(unix)]
#[test]
fn seed_dir_files_are_created_private_to_their_owner() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("dirmode");
let _ = std::fs::remove_dir_all(&dir);
let ci = CloudInit {
user_data: "#cloud-config\npassword: hunter2\n".into(),
meta_data: Some("instance-id: iid\nlocal-hostname: h\n".into()),
network_config: Some("version: 2\n".into()),
};
write_seed_dir(&dir, &ci).unwrap();
let dmode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
dmode,
0o700,
"seed directory {} is mode {dmode:o}",
dir.display()
);
for name in ["user-data", "meta-data", "network-config"] {
let p = dir.join(name);
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "{} is mode {mode:o}", p.display());
}
let _ = std::fs::remove_dir_all(&dir);
}
}