From d02a01a301fe1d27a8cc1ed678e99d0c631ed868 Mon Sep 17 00:00:00 2001 From: Moritz Gmeiner Date: Tue, 29 Jul 2025 20:35:41 +0200 Subject: [PATCH] current state --- Cargo.lock | 60 ++++++++++++ Cargo.toml | 4 + rustfmt.toml | 2 + src/dump.rs | 4 +- src/inode.rs | 45 +++++++++ src/lib.rs | 117 +++++++++++++++-------- src/mmp.rs | 4 + src/superblock.rs | 238 ++++++++++++++++++++++++++++++++++++++++++---- src/utils.rs | 44 +++++++++ 9 files changed, 455 insertions(+), 63 deletions(-) create mode 100644 src/inode.rs diff --git a/Cargo.lock b/Cargo.lock index 06f6295..7f348b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,22 +8,82 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bitflags-derive" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09648aa9dde04191fe237a510379c9eca42a97733b2065cc337922fba2bd378a" +dependencies = [ + "bitflags", + "bitflags-derive-macros", +] + +[[package]] +name = "bitflags-derive-macros" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d242ebf64d65693821c7e73a26d87757e83f795918fd42334da3dafaafddbd64" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "num-traits", +] + [[package]] name = "ext4" version = "0.1.0" dependencies = [ "anyhow", "bitflags", + "bitflags-derive", + "chrono", + "num-derive", + "num-traits", "static_assertions", "thiserror", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "proc-macro2" version = "1.0.95" diff --git a/Cargo.toml b/Cargo.toml index 0bea797..5970428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,5 +10,9 @@ path = "src/dump.rs" [dependencies] anyhow = "1.0.98" bitflags = "2.9.1" +bitflags-derive = "0.0.4" +chrono = { version = "0.4.41", default-features = false, features = ["std"] } +num-derive = "0.4.2" +num-traits = "0.2.19" static_assertions = "1.1.0" thiserror = "2.0.12" diff --git a/rustfmt.toml b/rustfmt.toml index e3b2313..a5617ab 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,6 +1,8 @@ imports_granularity = "Module" group_imports = "StdExternalCrate" +fn_call_width = 80 + # Activation of features, almost objectively better ;) use_try_shorthand = true use_field_init_shorthand = true diff --git a/src/dump.rs b/src/dump.rs index 4a8b3b9..d75411e 100644 --- a/src/dump.rs +++ b/src/dump.rs @@ -20,11 +20,11 @@ pub fn main() -> anyhow::Result<()> { let superblock = Superblock::from_bytes(&buf)?; - println!("Superblock 0 (offset: 1024): {:?}", superblock); + println!("Superblock 0 (offset: 1024): {}", superblock); let num_groups = superblock.blocks_count() / superblock.blocks_per_group() as u64; - let group_size = (superblock.blocks_per_group() as u64) * superblock.blocksize(); + let group_size = (superblock.blocks_per_group() as u64) * superblock.block_size(); println!("group_size: 0x{group_size:08X}"); diff --git a/src/inode.rs b/src/inode.rs new file mode 100644 index 0000000..37ecdbf --- /dev/null +++ b/src/inode.rs @@ -0,0 +1,45 @@ +use static_assertions::const_assert_eq; + +#[derive(Debug)] +#[repr(C)] +pub struct Inode { + mode: u16, + uid: u16, + size_lo: u32, + atime: u32, + ctime: u32, + mtime: u32, + dtime: u32, + gid: u16, + links_count: u16, + blocks_lo: u32, + flags: u32, + osd1: [u8; 4], + block: [u32; 15], + generation: u32, + file_acl_lo: u32, + size_high: u32, + obso_faddr: u32, + usd2: [u8; 12], + extra_isize: u16, + checksum_hi: u16, + ctime_extra: u32, + mtime_extra: u32, + atime_extra: u32, + crtime: u32, + crtime_extra: u32, + version_hi: u32, + proj_id: u32, +} + +const_assert_eq!(std::mem::size_of::(), 160); + +impl Inode { + pub fn from_bytes(bytes: &[u8; std::mem::size_of::()]) -> Result { + assert_eq!(bytes.len(), std::mem::size_of::()); + + let inode = unsafe { std::ptr::read(bytes.as_ptr() as *const Inode) }; + + Ok(inode) + } +} diff --git a/src/lib.rs b/src/lib.rs index 77e0f38..225a886 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,38 @@ -use std::io::SeekFrom; +use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +use crate::superblock::SuperblockError; pub use crate::superblock::{SUPERBLOCK_SIZE, Superblock}; pub use crate::utils::group_has_superblock; mod group_descriptor; +mod inode; mod mmp; mod superblock; mod utils; -pub trait FileLike { +const SUPPORTED_COMPATIBLE_FEATURES: superblock::CompatibleFeatures = + superblock::CompatibleFeatures::empty(); +const SUPPORTED_INCOMPATIBLE_FEATURES: superblock::IncompatibleFeatures = + superblock::IncompatibleFeatures::empty(); +const SUPPORTED_RO_COMPATIBLE_FEATURES: superblock::RoCompatibleFeatures = + superblock::RoCompatibleFeatures::empty(); + +#[derive(Debug, thiserror::Error)] +pub enum Ext4FsError { + #[error("Filesystem has unsupported incompatible features enabled: {0}")] + IncompatibleFeatures(String), + #[error("Superblock error: {0}")] + SuperblockError(#[from] SuperblockError), + #[error("Unknown error: {0}")] + UnknownError(#[from] anyhow::Error), +} + +pub trait SliceLike { fn read_at_offset(&mut self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()>; fn write_at_offset(&mut self, offset: u64, bytes: &[u8]) -> anyhow::Result<()>; - fn read_at_offset_const(&mut self, offset: u64) -> anyhow::Result<[u8; N]> { + fn read_array_at_offset(&mut self, offset: u64) -> anyhow::Result<[u8; N]> { let mut buf = [0; N]; self.read_at_offset(offset, &mut buf)?; @@ -22,7 +41,37 @@ pub trait FileLike { } } -impl FileLike for F { +impl SliceLike for &mut [u8] { + fn read_at_offset(&mut self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { + anyhow::ensure!( + offset as usize + buf.len() <= self.len(), + "reading {} bytes at offset {} is out of bounds for slice of len {}", + buf.len(), + offset, + self.len() + ); + + buf.copy_from_slice(&self[offset as usize..][..buf.len()]); + + Ok(()) + } + + fn write_at_offset(&mut self, offset: u64, bytes: &[u8]) -> anyhow::Result<()> { + anyhow::ensure!( + offset as usize + bytes.len() <= self.len(), + "writing {} bytes at offset {} is out of bounds for slice of len {}", + bytes.len(), + offset, + self.len() + ); + + self[offset as usize..][..bytes.len()].copy_from_slice(bytes); + + Ok(()) + } +} + +impl SliceLike for std::fs::File { fn read_at_offset(&mut self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { self.seek(SeekFrom::Start(offset))?; @@ -40,52 +89,38 @@ impl FileLike for F { } } -// impl FileLike for &mut [u8] { -// fn read_at_offset(&mut self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { -// anyhow::ensure!( -// offset as usize + buf.len() <= self.len(), -// "reading {} bytes at offset {} is out of bounds for slice of len {}", -// buf.len(), -// offset, -// self.len() -// ); -// -// buf.copy_from_slice(&self[offset as usize..][..buf.len()]); -// -// Ok(()) -// } -// -// fn write_at_offset(&mut self, offset: u64, bytes: &[u8]) -> anyhow::Result<()> { -// anyhow::ensure!( -// offset as usize + bytes.len() <= self.len(), -// "writing {} bytes at offset {} is out of bounds for slice of len {}", -// bytes.len(), -// offset, -// self.len() -// ); -// -// self[offset as usize..][..bytes.len()].copy_from_slice(bytes); -// -// Ok(()) -// } -// } - -pub struct Ext4Fs { +pub struct Ext4Fs { #[allow(dead_code)] - file: F, + data: S, superblock: superblock::Superblock, } -impl Ext4Fs { - pub fn load(file: F) -> Result, anyhow::Error> { - let mut file = file; +impl Ext4Fs { + pub fn load(data: S) -> Result, Ext4FsError> { + let mut file = data; - let superblock_bytes = file.read_at_offset_const::(1024)?; + let superblock_bytes = file.read_array_at_offset::(1024)?; let superblock = Superblock::from_bytes(&superblock_bytes)?; - Ok(Ext4Fs { file, superblock }) + let unsupported_incompat_features = superblock + .feature_incompat() + .difference(SUPPORTED_INCOMPATIBLE_FEATURES); + + if !unsupported_incompat_features.is_empty() { + let mut s = "".to_owned(); + + bitflags::parser::to_writer(&unsupported_incompat_features, &mut s) + .map_err(|e| anyhow::Error::new(e))?; + + return Err(Ext4FsError::IncompatibleFeatures(s)); + } + + Ok(Ext4Fs { + data: file, + superblock, + }) } pub fn super_block(&self) -> &Superblock { diff --git a/src/mmp.rs b/src/mmp.rs index eb7c37b..bcb7ba3 100644 --- a/src/mmp.rs +++ b/src/mmp.rs @@ -1,3 +1,5 @@ +use static_assertions::const_assert_eq; + #[derive(Debug)] #[repr(C)] pub struct Mmp { @@ -12,6 +14,8 @@ pub struct Mmp { checksum: u32, // Checksum of the MMP block } +const_assert_eq!(std::mem::size_of::(), 1024); + impl Mmp { pub fn from_bytes(bytes: &[u8]) -> Result { assert_eq!(bytes.len(), std::mem::size_of::()); diff --git a/src/superblock.rs b/src/superblock.rs index 5a7088b..c5bb55c 100644 --- a/src/superblock.rs +++ b/src/superblock.rs @@ -1,9 +1,16 @@ +use std::time::{Duration, SystemTime}; + use bitflags::bitflags; +use bitflags_derive::FlagsDisplay; +use chrono::{DateTime, Utc}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; use static_assertions::const_assert_eq; -use crate::utils::{combine_lo_hi, crc32}; +use crate::utils::{combine_lo_hi, crc32, uuid_to_string}; bitflags! { + #[derive(FlagsDisplay)] pub struct State: u16 { const VALID_FS = 0x0001; // cleanly unmounted const ERROR_FS = 0x0002; // errors detected @@ -13,6 +20,14 @@ bitflags! { } } +#[derive(Debug, FromPrimitive)] +#[repr(u16)] +pub enum Errors { + CONTINUE = 1, + RemountRo = 2, + Panic = 3, +} + #[non_exhaustive] #[repr(u32)] pub enum CreatorOs { @@ -23,15 +38,17 @@ pub enum CreatorOs { Lites = 4, } -#[non_exhaustive] +#[derive(Debug, FromPrimitive)] #[repr(u32)] +#[non_exhaustive] pub enum Revision { GoodOldExt4 = 0, DynamicInodes = 1, } bitflags! { - pub struct CompatibleFlags: u32 { + #[derive(FlagsDisplay)] + pub struct CompatibleFeatures: u32 { const DIR_PREALLOC = 0x1; // Directory preallocation const IMAGIC_INODES = 0x2; // “imagic inodes”. Not clear from the code what this does const HAS_JOURNAL = 0x4; // Has a journal @@ -64,6 +81,7 @@ bitflags! { } bitflags! { + #[derive(FlagsDisplay)] pub struct IncompatibleFeatures: u32 { const COMPRESSION = 0x1; // Compression const FILETYPE = 0x2; /* Directory entries record the file type. See @@ -97,6 +115,7 @@ bitflags! { } bitflags! { + #[derive(FlagsDisplay)] pub struct RoCompatibleFeatures: u32 { const SPARSE_SUPER = 0x1; /* Sparse superblocks. See the earlier discussion of this feature */ @@ -150,25 +169,27 @@ pub enum DefHashVersion { } bitflags! { + #[derive(FlagsDisplay)] pub struct DefaultMountOpts: u32 { - const EXT4_DEFM_DEBUG = 0x1; - const EXT4_DEFM_BSDGROUPS = 0x2; - const EXT4_DEFM_XATTR_USER = 0x4; - const EXT4_DEFM_ACL = 0x8; - const EXT4_DEFM_UID16 = 0x10; - const EXT4_DEFM_JMODE_DATA = 0x20; - const EXT4_DEFM_JMODE_ORDERED = 0x40; - const EXT4_DEFM_JMODE_WBACK = 0x60; - const EXT4_DEFM_NOBARRIER = 0x100; - const EXT4_DEFM_BLOCK_VALIDITY = 0x200; - const EXT4_DEFM_DISCARD = 0x400; - const EXT4_DEFM_NODELALLOC = 0x800; + const DEBUG = 0x1; + const BSDGROUPS = 0x2; + const XATTR_USER = 0x4; + const ACL = 0x8; + const UID16 = 0x10; + const JMODE_DATA = 0x20; + const JMODE_ORDERED = 0x40; + const JMODE_WBACK = 0x60; + const NOBARRIER = 0x100; + const BLOCK_VALIDITY = 0x200; + const DISCARD = 0x400; + const NODELALLOC = 0x800; const _ = !0; } } bitflags! { + #[derive(FlagsDisplay)] pub struct Flags: u32 { const SignedHash = 0x1; // Signed dirhash in use const UnsignedHash = 0x2; // Unsigned dirhash in use @@ -192,8 +213,10 @@ pub enum EncryptAlgos { pub enum SuperblockError { #[error("invalid value {value} for field {field}")] InvalidFieldValue { field: String, value: String }, - #[error("invalid checksum: expected {expected:X}, but found {actual:X}")] + #[error("invalid checksum: expected {expected:#X}, but found {actual:#X}")] InvalidChecksum { expected: u32, actual: u32 }, + #[error("invalid magic signature: expected 0xEF53, but found {0:#X}")] + InvalidMagic(u16), } #[derive(Debug)] @@ -210,9 +233,9 @@ pub struct Superblock { log_cluster_size: u32, /* Cluster size is 2 ^ (10 + s_log_cluster_size) blocks if bigalloc * is enabled. Otherwise s_log_cluster_size must equal * s_log_block_size */ - blocks_per_groups: u32, // Blocks per group + blocks_per_group: u32, // Blocks per group clusters_per_group: u32, /* Clusters per group, if bigalloc is enabled. Otherwise - * s_clusters_per_group must equal s_blocks_per_group */ + * s_clusters_per_group must equal s_blocks_per_group */ inodes_per_group: u32, // Inodes per group mtime: u32, // Mount time, in seconds since the epoch wtime: u32, // Write time, in seconds since the epoch @@ -361,6 +384,58 @@ pub struct Superblock { checksum: u32, // Superblock checksum } +impl std::fmt::Display for Superblock { + fn fmt(&self, mut f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Superblock {{")?; + + writeln!(f, " total inode count: {}", self.inodes_count())?; + writeln!(f, " total block count: {}", self.blocks_count())?; + writeln!(f, " reserved blocks: {}", self.r_blocks_count())?; + writeln!(f, " free blocks: {}", self.free_blocks_count())?; + writeln!(f, " free inodes: {}", self.free_inodes_count())?; + writeln!(f, " block size: {}", self.block_size())?; + writeln!(f, " cluster size: {}", self.cluster_size())?; + writeln!(f, " blocks per group: {}", self.blocks_per_group())?; + writeln!(f, " clusters per group: {}", self.clusters_per_group)?; + writeln!(f, " inodes per group: {}", self.inodes_per_group)?; + + writeln!(f, " mtime: {}", DateTime::::from(self.mtime()))?; + writeln!(f, " wtime: {}", DateTime::::from(self.wtime()))?; + + writeln!(f, " mount count: {}", self.mnt_count)?; + writeln!(f, " max mount count: {}", self.max_mnt_count)?; + + writeln!(f, " state: {}", self.state())?; + writeln!(f, " errors: {:?}", self.errors())?; + + writeln!(f, " last check: {}", DateTime::::from(self.lastcheck()))?; + writeln!(f, " check interval: {}", self.checkinterval())?; + + writeln!(f, " revision: {:?}", self.rev_level())?; + + writeln!(f, " feature compat: {}", self.feature_compat())?; + writeln!(f, " feature incompat: {}", self.feature_incompat())?; + writeln!(f, " feature ro_compat: {}", self.feature_ro_compat())?; + + writeln!(f, " uuid: {}", self.uuid_str())?; + writeln!(f, " volume name: \"{}\"", self.volume_name().unwrap_or(""))?; + writeln!(f, " last mounted: \"{}\"", self.last_mounted().unwrap_or(""))?; + + if self + .feature_compat() + .contains(CompatibleFeatures::HAS_JOURNAL) + { + writeln!(f, " journal uuid: {}", self.journal_uuid_str())?; + } + + writeln!(f, " default mount opts: {}", self.default_mount_opts())?; + + writeln!(f, " mkfs time: {}", DateTime::::from(self.mkfs_time()))?; + + Ok(()) + } +} + // offset of checksum + size of checksum pub const SUPERBLOCK_SIZE: usize = 1024; @@ -374,6 +449,10 @@ impl Superblock { let checksum = super_block.calc_checksum(); + if super_block.magic != 0xEF53 { + return Err(SuperblockError::InvalidMagic(super_block.magic)); + } + if super_block.checksum != checksum { return Err(SuperblockError::InvalidChecksum { expected: checksum, @@ -404,14 +483,26 @@ impl Superblock { } pub fn blocks_count(&self) -> u64 { + if !self.is_64bit() { + return self.blocks_count_lo as u64; + } + combine_lo_hi(self.blocks_count_lo, self.blocks_count_hi) } pub fn r_blocks_count(&self) -> u64 { + if !self.is_64bit() { + return self.r_blocks_count_lo as u64; + } + combine_lo_hi(self.r_blocks_count_lo, self.r_blocks_count_hi) } pub fn free_blocks_count(&self) -> u64 { + if !self.is_64bit() { + return self.free_blocks_count_lo as u64; + } + combine_lo_hi(self.free_blocks_count_lo, self.free_blocks_count_hi) } @@ -419,11 +510,118 @@ impl Superblock { self.free_inodes_count } - pub fn blocksize(&self) -> u64 { + pub fn block_size(&self) -> u64 { 1 << (10 + self.log_block_size) } + pub fn cluster_size(&self) -> u64 { + 1 << (10 + self.log_cluster_size) + } + pub fn blocks_per_group(&self) -> u32 { - self.blocks_per_groups + self.blocks_per_group + } + + pub fn clusters_per_group(&self) -> u32 { + self.clusters_per_group + } + + pub fn inodes_per_group(&self) -> u32 { + self.inodes_per_group + } + + pub fn mtime(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(self.mtime as u64)) + .unwrap() + } + + pub fn wtime(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(self.wtime as u64)) + .unwrap() + } + + pub fn state(&self) -> State { + State::from_bits_retain(self.state) + } + + pub fn errors(&self) -> Errors { + FromPrimitive::from_u16(self.errors).unwrap() + } + + pub fn lastcheck(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(self.lastcheck as u64)) + .unwrap() + } + + pub fn checkinterval(&self) -> u32 { + self.checkinterval + } + + pub fn rev_level(&self) -> Revision { + FromPrimitive::from_u32(self.rev_level).unwrap() + } + + pub fn feature_compat(&self) -> CompatibleFeatures { + CompatibleFeatures::from_bits_retain(self.feature_compat) + } + + pub fn feature_incompat(&self) -> IncompatibleFeatures { + IncompatibleFeatures::from_bits_retain(self.feature_incompat) + } + + pub fn feature_ro_compat(&self) -> RoCompatibleFeatures { + RoCompatibleFeatures::from_bits_retain(self.feature_ro_compat) + } + + pub fn uuid(&self) -> u128 { + u128::from_le_bytes(self.uuid) + } + + pub fn uuid_str(&self) -> String { + uuid_to_string(self.uuid) + } + + pub fn volume_name(&self) -> Option<&str> { + std::str::from_utf8(self.volume_name.as_slice()).ok() + } + + pub fn last_mounted(&self) -> Option<&str> { + std::str::from_utf8(self.last_mounted.as_slice()).ok() + } + + pub fn journal_uuid(&self) -> u128 { + assert!( + self.feature_compat() + .contains(CompatibleFeatures::HAS_JOURNAL) + ); + + u128::from_le_bytes(self.journal_uuid) + } + + pub fn journal_uuid_str(&self) -> String { + assert!( + self.feature_compat() + .contains(CompatibleFeatures::HAS_JOURNAL) + ); + + uuid_to_string(self.journal_uuid) + } + + pub fn default_mount_opts(&self) -> DefaultMountOpts { + DefaultMountOpts::from_bits_retain(self.default_mount_opts) + } + + pub fn mkfs_time(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(self.mkfs_time as u64)) + .unwrap() + } + + pub fn is_64bit(&self) -> bool { + self.feature_incompat() + .contains(IncompatibleFeatures::_64BIT) } } diff --git a/src/utils.rs b/src/utils.rs index 5c93aa8..0ce2ecb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -81,3 +81,47 @@ pub fn group_has_superblock(group_num: u64) -> bool { pub fn combine_lo_hi(lo: u32, hi: u32) -> u64 { lo as u64 | ((hi as u64) << 32) } + +pub fn uuid_to_string(src: [u8; 16]) -> String { + let mut s = String::with_capacity(36); + + // adapted from Uuid crate + + const LUT: [char; 16] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', + ]; + + let groups = [(0, 8), (9, 13), (14, 18), (19, 23), (24, 36)]; + + let mut group_idx = 0; + + let mut i = 0; + + while group_idx < 5 { + let (start, end) = groups[group_idx]; + + let mut j = start; + + while j < end { + let x = src[i]; + + i += 1; + + s.push(LUT[(x >> 4) as usize]); + s.push(LUT[(x & 0x0f) as usize]); + + j += 2; + } + + if group_idx < 4 { + s.push('-'); + } + + group_idx += 1; + } + + assert_eq!(s.len(), 36); + assert_eq!(s.capacity(), 36); + + s +}