diff --git a/.github/workflows/build-deb.yml b/.github/workflows/build-deb.yml index 5cb3c287..b7814548 100644 --- a/.github/workflows/build-deb.yml +++ b/.github/workflows/build-deb.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: build: diff --git a/.github/workflows/build-rpm.yml b/.github/workflows/build-rpm.yml index 156c10f4..8db01eec 100644 --- a/.github/workflows/build-rpm.yml +++ b/.github/workflows/build-rpm.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: create-tarball: diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index c63a45e6..daa3a245 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: clippy: diff --git a/.github/workflows/rust-fmt.yml b/.github/workflows/rust-fmt.yml index 7ceb79fc..97e6c85b 100644 --- a/.github/workflows/rust-fmt.yml +++ b/.github/workflows/rust-fmt.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: fmt: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index d2f90f49..90d87d7e 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: shellcheck: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6dcc4b35..108419ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,8 +7,6 @@ on: tags: - "v*.*.*" pull_request: - branches: - - 'master' jobs: test: diff --git a/Cargo.lock b/Cargo.lock index 1c518496..3eff991b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ dependencies = [ [[package]] name = "cryptpilot" -version = "0.3.4" +version = "0.3.5" dependencies = [ "again", "anyhow", @@ -943,7 +943,7 @@ dependencies = [ [[package]] name = "cryptpilot-crypt" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "async-trait", @@ -963,6 +963,7 @@ dependencies = [ "serde", "serde_json", "serde_variant", + "serial_test", "shadow-rs", "tokio", "tokio-util", @@ -975,7 +976,7 @@ dependencies = [ [[package]] name = "cryptpilot-fde" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "async-trait", @@ -1009,7 +1010,7 @@ dependencies = [ [[package]] name = "cryptpilot-verity" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "async-trait", @@ -3592,6 +3593,31 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot 0.12.3", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index feef7e88..bbdabded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" [workspace.package] authors = ["Kun Lai "] edition = "2021" -version = "0.3.4" +version = "0.3.5" [workspace.dependencies] again = "0.1.2" diff --git a/cryptpilot-core/src/fs/luks2.rs b/cryptpilot-core/src/fs/luks2.rs index 1cb9acda..fe8dd1ba 100644 --- a/cryptpilot-core/src/fs/luks2.rs +++ b/cryptpilot-core/src/fs/luks2.rs @@ -20,6 +20,21 @@ const LUKS2_VOLUME_KEY_SIZE_BIT_WITH_INTEGRITY: usize = 768; const LUKS2_VOLUME_KEY_SIZE_BIT_WITHOUT_INTEGRITY: usize = 512; const LUKS2_SECTOR_SIZE: u32 = 4096; const LUKS2_SUBSYSTEM_NAME: &str = "cryptpilot"; +const LUKS2_SUBSYSTEM_INITIALIZING: &str = "cryptpilot-initializing"; + +/// Represents the initialization state of a LUKS2 volume managed by cryptpilot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VolumeInitState { + /// No LUKS2 header, or LUKS2 header exists but has no cryptpilot subsystem marker. + /// Safe to format. + None, + /// LUKS2 header exists with subsystem="cryptpilot-initializing". + /// A previous init was interrupted mid-way. Safe to re-init. + Initializing, + /// LUKS2 header exists with subsystem="cryptpilot". + /// Volume is fully initialized and ready to open. + Ready, +} async fn get_luks2_subsystem(dev: &Path) -> Result> { /// LUKS2 header structure according to the specification @@ -93,27 +108,29 @@ pub async fn format(dev: &Path, passphrase: &Passphrase, integrity: IntegrityTyp libcryptsetup_rs::set_debug_level(CryptDebugLevel::None); } - let params = CryptParamsLuks2 { - integrity: Some("hmac(sha256)".to_owned()), + let mut params = CryptParamsLuks2 { + integrity: None, pbkdf: None, integrity_params: None, data_alignment: 0, data_device: None, sector_size: LUKS2_SECTOR_SIZE, label: None, - subsystem: None, + subsystem: Some(LUKS2_SUBSYSTEM_INITIALIZING.to_owned()), }; - let mut params_ref = (¶ms).try_into()?; let volume_key = match integrity { IntegrityType::None => { libcryptsetup_rs::Either::Right(LUKS2_VOLUME_KEY_SIZE_BIT_WITHOUT_INTEGRITY / 8) } IntegrityType::Journal | IntegrityType::NoJournal => { + params.integrity = Some("hmac(sha256)".to_owned()); libcryptsetup_rs::Either::Right(LUKS2_VOLUME_KEY_SIZE_BIT_WITH_INTEGRITY / 8) } }; + let mut params_ref = (¶ms).try_into()?; + let mut device = CryptInit::init(&device_path)?; device.context_handle().format::( @@ -121,10 +138,7 @@ pub async fn format(dev: &Path, passphrase: &Passphrase, integrity: IntegrityTyp ("aes", "xts-plain64"), None, volume_key, - match integrity { - IntegrityType::None => None, - IntegrityType::Journal | IntegrityType::NoJournal => Some(&mut params_ref), - }, + Some(&mut params_ref), )?; device.keyslot_handle().add_by_key( None, @@ -260,42 +274,33 @@ pub async fn is_initialized(dev: &Path) -> Result { is_a_cryptpilot_initialized_luks2_volume(dev).await } -async fn is_a_cryptpilot_initialized_luks2_volume(dev: &Path) -> Result { - let verbose = get_verbose().await; - let device_path = PathBuf::from(&dev); - - // First check if it's a LUKS2 volume using the blocking operation - let is_luks2 = tokio::task::spawn_blocking(move || { - if verbose { - libcryptsetup_rs::set_debug_level(CryptDebugLevel::All); - } else { - libcryptsetup_rs::set_debug_level(CryptDebugLevel::None); - } - - let mut device = CryptInit::init(&device_path)?; - - let load_success = device.context_handle().load::<()>(None, None).is_ok(); - - let is_luks2 = - load_success && device.format_handle().get_type()? == EncryptionFormat::Luks2; - - Ok::<_, anyhow::Error>(is_luks2) - }) - .await? - .with_context(|| format!("Failed to check luks2 initialization status of device {dev:?}"))?; +/// Returns the initialization state of a LUKS2 volume. +/// +/// - `None`: no valid LUKS2 header, or header exists but has no cryptpilot marker +/// - `Initializing`: subsystem is "cryptpilot-initializing" (partial init) +/// - `Ready`: subsystem is "cryptpilot" (fully initialized) +pub async fn get_init_state(dev: &Path) -> Result { + // Try to read the subsystem from the raw header. + // If the device is not a valid LUKS2 volume or the header can't be read, + // return None. + let subsystem = match get_luks2_subsystem(dev).await { + Ok(Some(s)) => s, + Ok(None) => return Ok(VolumeInitState::None), + Err(_) => return Ok(VolumeInitState::None), + }; - if !is_luks2 { - return Ok(false); + if subsystem == LUKS2_SUBSYSTEM_NAME { + Ok(VolumeInitState::Ready) + } else if subsystem == LUKS2_SUBSYSTEM_INITIALIZING { + Ok(VolumeInitState::Initializing) + } else { + Ok(VolumeInitState::None) } +} - // Check if the subsystem is set to "cryptpilot" - let subsystem_is_set = get_luks2_subsystem(dev) - .await - .with_context(|| format!("Failed to get LUKS2 device subsystem for {dev:?}"))? - .map(|subsystem| subsystem == LUKS2_SUBSYSTEM_NAME) - .unwrap_or(false); - - Ok(subsystem_is_set) +async fn is_a_cryptpilot_initialized_luks2_volume(dev: &Path) -> Result { + let state = get_init_state(dev).await?; + Ok(state == VolumeInitState::Ready) } pub fn is_active(volume: &str) -> bool { @@ -372,3 +377,23 @@ impl Drop for TempLuksVolume { }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_volume_init_state_variants() { + // Verify enum derives and values are correct + assert_eq!(VolumeInitState::None, VolumeInitState::None); + assert_ne!(VolumeInitState::Ready, VolumeInitState::Initializing); + assert_ne!(VolumeInitState::Ready, VolumeInitState::None); + } + + #[test] + fn test_subsystem_constants() { + assert_eq!(LUKS2_SUBSYSTEM_NAME, "cryptpilot"); + assert_eq!(LUKS2_SUBSYSTEM_INITIALIZING, "cryptpilot-initializing"); + assert_ne!(LUKS2_SUBSYSTEM_NAME, LUKS2_SUBSYSTEM_INITIALIZING); + } +} diff --git a/cryptpilot-crypt/Cargo.toml b/cryptpilot-crypt/Cargo.toml index 29a8a257..2787f90c 100644 --- a/cryptpilot-crypt/Cargo.toml +++ b/cryptpilot-crypt/Cargo.toml @@ -38,5 +38,6 @@ cgroups-rs = "0.3.4" ctor = "=0.4.1" rstest = "0.25.0" rstest_reuse = "0.7.0" +serial_test = "3.0" tokio-util = {workspace = true} two-rusty-forks = {version = "0.4.0", features = ["macro"]} diff --git a/cryptpilot-crypt/src/cmd/init.rs b/cryptpilot-crypt/src/cmd/init.rs index 94fb6ed0..13a23a1e 100644 --- a/cryptpilot-crypt/src/cmd/init.rs +++ b/cryptpilot-crypt/src/cmd/init.rs @@ -66,8 +66,8 @@ async fn persistent_disk_init( status.description ); } - VolumeStatusKind::RequiresInit => { - // This is expected, continue with initialization + VolumeStatusKind::RequiresInit | VolumeStatusKind::Initializing => { + // This is expected (or init was interrupted), continue with initialization } VolumeStatusKind::ReadyToOpen => { if !init_options.force_reinit { diff --git a/cryptpilot-crypt/src/cmd/show.rs b/cryptpilot-crypt/src/cmd/show.rs index 6940db45..7f10b97c 100644 --- a/cryptpilot-crypt/src/cmd/show.rs +++ b/cryptpilot-crypt/src/cmd/show.rs @@ -19,8 +19,10 @@ pub enum VolumeStatusKind { DeviceNotFound, /// Device exists but initialization check failed (with error details) CheckFailed, - /// Device requires initialization + /// Device requires initialization (raw disk or no marker) RequiresInit, + /// Device is in initializing state (partial init, interrupted) + Initializing, /// Volume is ready to open (either initialized persistent volume or temporary volume) ReadyToOpen, /// Volume is currently opened/mapped @@ -153,6 +155,7 @@ impl PrintAsTable for [VolumeConfig] { VolumeStatusKind::Opened => Color::Green, VolumeStatusKind::ReadyToOpen => Color::Green, VolumeStatusKind::RequiresInit => Color::Yellow, + VolumeStatusKind::Initializing => Color::Yellow, VolumeStatusKind::CheckFailed => Color::Red, VolumeStatusKind::DeviceNotFound => Color::Red, }; @@ -261,16 +264,23 @@ impl VolumeConfig { }; } - // For persistent volumes, check initialization status - match cryptpilot::fs::luks2::is_initialized(&self.dev).await { - Ok(true) => VolumeStatus { + // For persistent volumes, check initialization state + match cryptpilot::fs::luks2::get_init_state(&self.dev).await { + Ok(cryptpilot::fs::luks2::VolumeInitState::Ready) => VolumeStatus { kind: VolumeStatusKind::ReadyToOpen, description: format!( "Device '{:?}' is properly initialized as LUKS2 volume and ready to open", self.dev ), }, - Ok(false) => VolumeStatus { + Ok(cryptpilot::fs::luks2::VolumeInitState::Initializing) => VolumeStatus { + kind: VolumeStatusKind::Initializing, + description: format!( + "\u{26a0} Device '{:?}' is in initializing state - previous initialization was interrupted", + self.dev + ), + }, + Ok(cryptpilot::fs::luks2::VolumeInitState::None) => VolumeStatus { kind: VolumeStatusKind::RequiresInit, description: format!( "Device '{:?}' exists but is not a valid LUKS2 volume - needs initialization", @@ -278,7 +288,6 @@ impl VolumeConfig { ), }, Err(e) => { - // This is the critical case - check failed let error_msg = format!("{:?}", e); VolumeStatus { kind: VolumeStatusKind::CheckFailed, diff --git a/cryptpilot-crypt/tests/three_state_init.rs b/cryptpilot-crypt/tests/three_state_init.rs new file mode 100644 index 00000000..7e8addd3 --- /dev/null +++ b/cryptpilot-crypt/tests/three_state_init.rs @@ -0,0 +1,193 @@ +// Three-state initialization integration tests +// Tests the None → Initializing → Ready lifecycle and interrupted init recovery + +use std::path::Path; + +use cryptpilot::fs::{ + block::dummy::DummyDevice, + cmd::CheckCommandOutput as _, + luks2::{format, get_init_state, is_initialized, mark_volume_as_initialized, VolumeInitState}, +}; +use cryptpilot::types::{IntegrityType, MakeFsType, Passphrase}; + +use anyhow::Result; +use tokio::process::Command; + +/// Test: get_init_state returns None for a raw dummy device +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_init_state_none_for_raw_device() -> Result<()> { + let dummy = DummyDevice::setup_on_tmpfs(100 * 1024 * 1024).await?; + let state = get_init_state(Path::new(&dummy.path()?)).await?; + assert_eq!(state, VolumeInitState::None); + Ok(()) +} + +/// Test: get_init_state returns Initializing after format() +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_init_state_initializing_after_format() -> Result<()> { + let dummy = DummyDevice::setup_on_tmpfs(100 * 1024 * 1024).await?; + let passphrase = Passphrase::from(b"test-passphrase-1234567890123456".to_vec()); + + format(Path::new(&dummy.path()?), &passphrase, IntegrityType::None).await?; + + let state = get_init_state(Path::new(&dummy.path()?)).await?; + assert_eq!(state, VolumeInitState::Initializing); + Ok(()) +} + +/// Test: get_init_state returns Ready after mark_volume_as_initialized() +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_init_state_ready_after_mark() -> Result<()> { + let dummy = DummyDevice::setup_on_tmpfs(100 * 1024 * 1024).await?; + let passphrase = Passphrase::from(b"test-passphrase-1234567890123456".to_vec()); + + format(Path::new(&dummy.path()?), &passphrase, IntegrityType::None).await?; + mark_volume_as_initialized(Path::new(&dummy.path()?)).await?; + + let state = get_init_state(Path::new(&dummy.path()?)).await?; + assert_eq!(state, VolumeInitState::Ready); + Ok(()) +} + +/// Test: is_initialized returns true only for Ready state +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_is_initialized_only_true_for_ready() -> Result<()> { + let dummy = DummyDevice::setup_on_tmpfs(100 * 1024 * 1024).await?; + let passphrase = Passphrase::from(b"test-passphrase-1234567890123456".to_vec()); + + // Raw device: is_initialized = false + assert!(!is_initialized(Path::new(&dummy.path()?)).await?); + + // After format: is_initialized = false (Initializing state) + format(Path::new(&dummy.path()?), &passphrase, IntegrityType::None).await?; + assert!(!is_initialized(Path::new(&dummy.path()?)).await?); + + // After mark: is_initialized = true (Ready state) + mark_volume_as_initialized(Path::new(&dummy.path()?)).await?; + assert!(is_initialized(Path::new(&dummy.path()?)).await?); + + Ok(()) +} + +/// Test: full init lifecycle (None → Initializing → Ready) with blkid probing +/// +/// Performs a full initialization (format + mkfs + mark). After each step, +/// verifies the state via `get_init_state()` AND checks that `blkid -p` +/// reports the correct SUBSYSTEM field. +/// +/// Uses `serial_test` to avoid interference from other parallel tests. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_full_init_lifecycle_with_blkid_probe() -> Result<()> { + let dummy = DummyDevice::setup_on_tmpfs(100 * 1024 * 1024).await?; + let dev_path = dummy.path()?; + + let passphrase = Passphrase::from(b"test-passphrase-1234567890123456".to_vec()); + + // Step 1: Verify raw device is None + let state = get_init_state(Path::new(&dev_path)).await?; + assert_eq!(state, VolumeInitState::None, "Expected None before format"); + + // Step 2: Format → Initializing + format(Path::new(&dev_path), &passphrase, IntegrityType::None).await?; + let state = get_init_state(Path::new(&dev_path)).await?; + assert_eq!( + state, + VolumeInitState::Initializing, + "Expected Initializing after format" + ); + // Wait and retry blkid until it sees the state (up to 3s) + wait_for_blkid_subsystem(dev_path.to_str().unwrap(), "cryptpilot-initializing", 30).await?; + + // Step 3: Open LUKS + mkfs + let tmp_volume = cryptpilot::fs::luks2::TempLuksVolume::open( + Path::new(&dev_path), + &passphrase, + IntegrityType::None, + ) + .await?; + cryptpilot::fs::mkfs::force_mkfs( + &tmp_volume.volume_path(), + &MakeFsType::Ext4, + IntegrityType::None, + ) + .await?; + drop(tmp_volume); + + // Still Initializing after mkfs + let state = get_init_state(Path::new(&dev_path)).await?; + assert_eq!( + state, + VolumeInitState::Initializing, + "Expected Initializing after mkfs" + ); + + // Step 4: Mark as ready → Ready + mark_volume_as_initialized(Path::new(&dev_path)).await?; + let state = get_init_state(Path::new(&dev_path)).await?; + assert_eq!(state, VolumeInitState::Ready, "Expected Ready after mark"); + // Wait and retry blkid until it sees the state (up to 5s) + wait_for_blkid_subsystem(dev_path.to_str().unwrap(), "cryptpilot", 50).await?; + + Ok(()) +} + +/// Poll blkid until it reports the expected SUBSYSTEM value. +/// +/// Retries `max_attempts` times with 100ms between attempts. +/// Returns Ok if the expected value is observed, Err if not. +async fn wait_for_blkid_subsystem( + dev_path: &str, + expected: &str, + max_attempts: usize, +) -> Result<()> { + for attempt in 1..=max_attempts { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let result = Command::new("blkid") + .arg("-p") + .arg("-c") + .arg("/dev/null") + .arg("-o") + .arg("export") + .arg(dev_path) + .run_with_status_checker(|code, stdout, stderr| { + // blkid -p exits with 2 if no signature found, 0 if found + if code == 0 || code == 2 { + Ok(String::from_utf8_lossy(&stdout).to_string()) + } else { + Err(anyhow::anyhow!( + "blkid failed with code {}: stderr={}", + code, + String::from_utf8_lossy(&stderr) + )) + } + }) + .await; + if let Ok(stdout) = result { + if let Some(subsystem) = extract_blkid_field(&stdout, "SUBSYSTEM") { + if subsystem == expected { + return Ok(()); + } + } + } + if attempt == max_attempts { + anyhow::bail!( + "blkid did not report SUBSYSTEM={} after {} attempts ({}ms)", + expected, + max_attempts, + max_attempts * 100 + ); + } + } + Ok(()) +} + +/// Extract a field value from blkid -p -o export output. +fn extract_blkid_field(output: &str, key: &str) -> Option { + for line in output.lines() { + if line.starts_with(&format!("{}=", key)) { + return Some(line[key.len() + 1..].to_string()); + } + } + None +} diff --git a/cryptpilot.spec b/cryptpilot.spec index 628ea853..b320ac45 100644 --- a/cryptpilot.spec +++ b/cryptpilot.spec @@ -2,7 +2,7 @@ %define release_num 1 Name: cryptpilot -Version: 0.3.4 +Version: 0.3.5 Release: %{release_num}%{?dist} Summary: Full-disk encryption and data protection tool for confidential computing Group: Applications/System @@ -271,6 +271,10 @@ fi %changelog +* Thu Jun 25 2026 Kun Lai - 0.3.5-1 +- ci: run workflows on PRs to any branch +- feat(core): add VolumeInitState enum and get_init_state() API + * Tue Feb 03 2026 Kun Lai - 0.3.4-1 - fix: correct is_empty_disk logic and streamline filesystem creation - fix: replace makefs_if_empty with force_mkfs for reliable volume initialization diff --git a/debian/changelog b/debian/changelog index 46ab4ffc..983c455c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +cryptpilot (0.3.5) unstable; urgency=medium + + * ci: run workflows on PRs to any branch + * feat(core): add VolumeInitState enum and get_init_state() API + + -- Kun Lai Thu, 25 Jun 2026 20:34:17 +0800 + cryptpilot (0.3.1-1) unstable; urgency=medium * feat(kbs): support both one-shot and daemon modes for CDH