diff --git a/Cargo.lock b/Cargo.lock index 16ebf462..283037bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -44,11 +50,13 @@ dependencies = [ "ctor", "flexi_logger", "log", + "miniz_oxide", "pretty_assertions", "rstest", "serde", "serde-aco", "snafu", + "zerocopy", ] [[package]] @@ -486,6 +494,16 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miniz_oxide" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5faa9f23e86bd5768d76def086192ff5f869fb088da12a976ea21e9796b975f6" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -784,6 +802,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" diff --git a/Cargo.toml b/Cargo.toml index 4d8d6bb5..4954cf4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ quote = { version = "1" } rstest = "0.26" tempfile = "3" pretty_assertions = "1" +zerocopy = { version = "0.8.31", features = ["derive", "alloc"] } [profile.release] lto = true diff --git a/alioth-cli/Cargo.toml b/alioth-cli/Cargo.toml index 10d80a09..07980f68 100644 --- a/alioth-cli/Cargo.toml +++ b/alioth-cli/Cargo.toml @@ -14,8 +14,10 @@ flexi_logger.workspace = true clap = { version = "4", features = ["derive"] } snafu.workspace = true alioth.workspace = true +miniz_oxide = { version = "0.9", features = ["simd"] } serde.workspace = true serde-aco.workspace = true +zerocopy.workspace = true [[bin]] path = "src/main.rs" diff --git a/alioth-cli/src/img.rs b/alioth-cli/src/img.rs new file mode 100644 index 00000000..0741f2f0 --- /dev/null +++ b/alioth-cli/src/img.rs @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fs::File; +use std::io::Read; +use std::os::unix::fs::FileExt; +use std::path::Path; + +use alioth::blk::qcow2::{ + QCOW2_MAGIC, Qcow2CmprDesc, Qcow2Hdr, Qcow2IncompatibleFeatures, Qcow2L1, Qcow2L2, Qcow2StdDesc, +}; +use alioth::errors::{DebugTrace, trace_error}; +use alioth::utils::endian::Bu64; +use clap::{Args, Subcommand}; +use miniz_oxide::inflate::TINFLStatus; +use miniz_oxide::inflate::core::inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF; +use miniz_oxide::inflate::core::{DecompressorOxide, decompress}; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; +use zerocopy::{FromZeros, IntoBytes}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum ImageFormat { + #[serde(alias = "qcow2")] + Qcow2, + #[serde(alias = "raw")] + Raw, +} + +#[derive(Args, Debug)] +pub struct ImgArgs { + #[command(subcommand)] + cmd: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Convert an image from one format to another. + Convert(ConvertArgs), +} + +#[derive(Args, Debug)] +struct ConvertArgs { + /// Input file format + #[arg(short = 'f', long, default_value = "qcow2")] + source_format: Box, + + /// Output file format + #[arg(short = 'O', long, default_value = "raw")] + target_format: Box, + + /// Input file + input: Box, + + /// Output file + output: Box, +} + +#[trace_error] +#[derive(Snafu, DebugTrace)] +#[snafu(module, context(suffix(false)))] +pub enum Error { + #[snafu(display("Error from OS"), context(false))] + System { error: std::io::Error }, + #[snafu(display("Failed to parse {arg}"))] + ParseArg { + arg: String, + error: serde_aco::Error, + }, + #[snafu(display("Failed to convert image from {from:?} to {to:?}"))] + Conversion { from: ImageFormat, to: ImageFormat }, + #[snafu(display("Missing magic number {magic:x?}, found {found:x?}"))] + MissingMagic { magic: [u8; 4], found: [u8; 4] }, + #[snafu(display("Unsupported qcow2 features: {features:?}"))] + Features { features: Qcow2IncompatibleFeatures }, + #[snafu(display("Decompression failed: {:?}", status))] + DecompressionFailed { status: TINFLStatus }, +} + +type Result = std::result::Result; + +pub fn exec(args: ImgArgs) -> Result<()> { + match args.cmd { + Command::Convert(args) => convert(args), + } +} + +fn convert(args: ConvertArgs) -> Result<()> { + let from: ImageFormat = serde_aco::from_arg(&args.source_format).context(error::ParseArg { + arg: args.source_format, + })?; + let to: ImageFormat = serde_aco::from_arg(&args.target_format).context(error::ParseArg { + arg: args.target_format, + })?; + if from == ImageFormat::Qcow2 && to == ImageFormat::Raw { + convert_qcow2_to_raw(&args.input, &args.output) + } else { + error::Conversion { from, to }.fail() + } +} + +fn convert_qcow2_to_raw(input: &Path, output: &Path) -> Result<()> { + let mut hdr = Qcow2Hdr::new_zeroed(); + let mut f = File::open(input)?; + f.read_exact(hdr.as_mut_bytes())?; + if hdr.magic != QCOW2_MAGIC { + return error::MissingMagic { + magic: QCOW2_MAGIC, + found: hdr.magic, + } + .fail(); + } + let features = hdr.incompatible_features.to_ne(); + if hdr.version.to_ne() > 2 && features != 0 { + let features = Qcow2IncompatibleFeatures::from_bits_retain(features); + return error::Features { features }.fail(); + } + let cluster_bits = hdr.cluster_bits.to_ne(); + let cluster_size = 1 << cluster_bits; + let l2_size = cluster_size / std::mem::size_of::() as u64; + + let mut l1_table = vec![Bu64::new_zeroed(); hdr.l1_size.to_ne() as usize]; + f.read_exact_at(l1_table.as_mut_bytes(), hdr.l1_table_offset.to_ne())?; + + let output = File::create(output)?; + output.set_len(hdr.size.to_ne())?; + + let mut data = vec![0u8; cluster_size as usize]; + let mut tmp_buf = vec![0u8; cluster_size as usize]; + let mut l2_table = vec![Bu64::new_zeroed(); l2_size as usize]; + + let mut decompressor = DecompressorOxide::new(); + + for (l1_index, l1_entry) in l1_table.iter().enumerate() { + let l1_entry = Qcow2L1(l1_entry.to_ne()); + let l2_offset = l1_entry.l2_offset(); + if l2_offset == 0 { + continue; + } + f.read_exact_at(l2_table.as_mut_bytes(), l2_offset)?; + for (l2_index, l2_entry) in l2_table.iter().enumerate() { + let l2_entry = Qcow2L2(l2_entry.to_ne()); + if l2_entry.compressed() { + let l2_desc = Qcow2CmprDesc(l2_entry.desc()); + let (offset, size) = l2_desc.offset_size(cluster_bits); + let buf = if let Some(buf) = tmp_buf.get_mut(..size as usize) { + buf + } else { + tmp_buf.resize(size as usize, 0); + tmp_buf.as_mut() + }; + f.read_exact_at(buf, offset)?; + decompressor.init(); + let flag = TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF; + let (status, _, _) = decompress(&mut decompressor, buf, &mut data, 0, flag); + if status != TINFLStatus::Done { + return error::DecompressionFailed { status }.fail(); + } + } else { + let l2_desc = Qcow2StdDesc(l2_entry.desc()); + if l2_desc.zero() { + continue; + } + if !l2_entry.rc1() && l2_desc.offset() == 0 { + continue; + } + let offset = l2_desc.cluster_offset(); + f.read_exact_at(&mut data, offset)?; + } + let output_offset = (l1_index as u64 * l2_size + l2_index as u64) << cluster_bits; + output.write_all_at(&data, output_offset)?; + } + } + Ok(()) +} diff --git a/alioth-cli/src/main.rs b/alioth-cli/src/main.rs index 86061aaf..203e4917 100644 --- a/alioth-cli/src/main.rs +++ b/alioth-cli/src/main.rs @@ -13,6 +13,7 @@ // limitations under the License. mod boot; +mod img; mod objects; #[cfg(target_os = "linux")] mod vu; @@ -29,6 +30,8 @@ enum Command { #[cfg(target_os = "linux")] /// Start a vhost-user backend device. Vu(Box), + /// Manipulate disk images. + Img(Box), } #[derive(Parser, Debug)] @@ -79,6 +82,7 @@ fn main() -> Result<(), Box> { Command::Boot(args) => boot::boot(*args)?, #[cfg(target_os = "linux")] Command::Vu(args) => vu::start(*args)?, + Command::Img(args) => img::exec(*args)?, } Ok(()) } diff --git a/alioth/Cargo.toml b/alioth/Cargo.toml index c0143012..982f50fa 100644 --- a/alioth/Cargo.toml +++ b/alioth/Cargo.toml @@ -11,7 +11,6 @@ license.workspace = true test-hv = [] [dependencies] -zerocopy = { version = "0.8.31", features = ["derive", "alloc"] } bitflags = "2.9.4" bitfield = "0.19.4" log = "0.4" @@ -22,6 +21,7 @@ alioth-macros.workspace = true serde.workspace = true serde-aco.workspace = true snafu.workspace = true +zerocopy.workspace = true [target.'cfg(target_os = "linux")'.dependencies] io-uring = "0.7" diff --git a/alioth/src/blk/blk.rs b/alioth/src/blk/blk.rs new file mode 100644 index 00000000..a61505ca --- /dev/null +++ b/alioth/src/blk/blk.rs @@ -0,0 +1,15 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub mod qcow2; diff --git a/alioth/src/blk/qcow2.rs b/alioth/src/blk/qcow2.rs new file mode 100644 index 00000000..51e6c4e2 --- /dev/null +++ b/alioth/src/blk/qcow2.rs @@ -0,0 +1,140 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alioth_macros::Layout; +use bitfield::bitfield; +use bitflags::bitflags; +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout}; + +use crate::c_enum; +use crate::utils::endian::{Bu32, Bu64}; + +#[repr(C)] +#[derive(Debug, Clone, Layout, KnownLayout, Immutable, FromBytes, IntoBytes)] +/// Qcow2 Header +/// +/// [Specification](https://qemu-project.gitlab.io/qemu/interop/qcow2.html#header) +pub struct Qcow2Hdr { + pub magic: [u8; 4], + pub version: Bu32, + pub backing_file_offset: Bu64, + pub backing_file_size: Bu32, + pub cluster_bits: Bu32, + pub size: Bu64, + pub crypt_method: Bu32, + pub l1_size: Bu32, + pub l1_table_offset: Bu64, + pub refcount_table_offset: Bu64, + pub refcount_table_clusters: Bu32, + pub nb_snapshots: Bu32, + pub snapshots_offset: Bu64, + pub incompatible_features: Bu64, + pub compatible_features: Bu64, + pub autoclear_features: Bu64, + pub refcount_order: Bu32, + pub header_length: Bu32, + pub compression_type: Qcow2Compression, + pub padding: [u8; 7], +} + +/// Qcow2 Magic Number "QFI\xfb" +pub const QCOW2_MAGIC: [u8; 4] = *b"QFI\xfb"; + +bitflags! { + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct Qcow2IncompatibleFeatures: u64 { + const DIRTY = 1 << 0; + const CORRUPT = 1 << 1; + const EXTERNAL_DATA = 1 << 2; + const COMPRESSION = 1 << 3; + const EXTERNAL_L2 = 1 << 4; + } +} + +bitflags! { + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct Qcow2CompatibleFeatures: u64 { + const LAZY_REFCOUNTS = 1 << 0; + } +} + +c_enum! { + #[derive(Default, Immutable, KnownLayout, FromBytes, IntoBytes)] + pub struct Qcow2Compression(u8); + { + DEFLATE = 0; + ZSTD = 1; + } +} + +bitfield! { + /// QCOW2 L1 Table Entry + #[derive(Copy, Clone, Default, PartialEq, Eq, Hash, KnownLayout, Immutable, FromBytes, IntoBytes)] + #[repr(transparent)] + pub struct Qcow2L1(u64); + impl Debug; + pub rc1, _: 63; + pub offset, _: 55, 9; +} + +impl Qcow2L1 { + pub fn l2_offset(&self) -> u64 { + self.0 & 0xff_ffff_ffff_ff00 + } +} + +bitfield! { + #[derive(Copy, Clone, Default, PartialEq, Eq, Hash, KnownLayout, Immutable, FromBytes, IntoBytes)] + #[repr(transparent)] + pub struct Qcow2L2(u64); + impl Debug; + pub desc, _: 61, 0; + pub compressed, _: 62; + pub rc1, _: 63; +} + +bitfield! { + #[derive(Copy, Clone, Default, PartialEq, Eq, Hash, KnownLayout, Immutable, FromBytes, IntoBytes)] + #[repr(transparent)] + pub struct Qcow2StdDesc(u64); + impl Debug; + pub offset, _: 55, 9; + pub zero, _: 0; +} + +impl Qcow2StdDesc { + pub fn cluster_offset(&self) -> u64 { + self.0 & 0xff_ffff_ffff_ff00 + } +} + +pub const QCOW2_CMPR_SECTOR_SIZE: u64 = 512; + +#[derive(Debug)] +pub struct Qcow2CmprDesc(pub u64); + +impl Qcow2CmprDesc { + pub fn offset_size(&self, cluster_bits: u32) -> (u64, u64) { + let size_bits = cluster_bits - 8; + let offset_bits = 62 - size_bits; + let offset = self.0 & ((1 << offset_bits) - 1); + let sectors = (self.0 >> offset_bits) & ((1 << size_bits) - 1); + let size = (1 + sectors) * QCOW2_CMPR_SECTOR_SIZE - (offset & (QCOW2_CMPR_SECTOR_SIZE - 1)); + (offset, size) + } +} + +#[cfg(test)] +#[path = "qcow2_test.rs"] +mod tests; diff --git a/alioth/src/blk/qcow2_test.rs b/alioth/src/blk/qcow2_test.rs new file mode 100644 index 00000000..27475d11 --- /dev/null +++ b/alioth/src/blk/qcow2_test.rs @@ -0,0 +1,36 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use rstest::rstest; + +use crate::blk::qcow2::{Qcow2CmprDesc, Qcow2L1, Qcow2StdDesc}; + +#[rstest] +#[case(Qcow2L1(0xfe002cd | (1 << 63)), 0xfe00200)] +fn test_l1entry_l2_offset(#[case] entry: Qcow2L1, #[case] offset: u64) { + assert_eq!(entry.l2_offset(), offset) +} + +#[rstest] +#[case(Qcow2StdDesc(0xfe00201), 0xfe00200)] +fn test_std_desc_cluster_offset(#[case] desc: Qcow2StdDesc, #[case] offset: u64) { + assert_eq!(desc.cluster_offset(), offset) +} + +#[rstest] +#[case(Qcow2CmprDesc(0x100210), 0x100210, 0x1f0)] +#[case(Qcow2CmprDesc(0x100200 | (1 << 54)), 0x100200, 0x400)] +fn test_cmpr_desc_offset_size(#[case] desc: Qcow2CmprDesc, #[case] offset: u64, #[case] size: u64) { + assert_eq!(desc.offset_size(16), (offset, size)) +} diff --git a/alioth/src/lib.rs b/alioth/src/lib.rs index 46a7fdb8..e143a361 100644 --- a/alioth/src/lib.rs +++ b/alioth/src/lib.rs @@ -14,6 +14,8 @@ #[path = "arch/arch.rs"] pub mod arch; +#[path = "blk/blk.rs"] +pub mod blk; #[path = "board/board.rs"] pub mod board; #[path = "device/device.rs"] @@ -36,7 +38,7 @@ pub mod sync; #[path = "sys/sys.rs"] pub mod sys; #[path = "utils/utils.rs"] -pub(crate) mod utils; +pub mod utils; #[cfg(target_os = "linux")] #[path = "vfio/vfio.rs"] pub mod vfio;