diff --git a/Cargo.lock b/Cargo.lock index e22feec..e27099e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "avocadoctl" -version = "0.2.0" +version = "0.2.1" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index 19b6d15..acd69d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "avocadoctl" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Avocado Linux control CLI tool" authors = ["Avocado"] diff --git a/src/commands/ext.rs b/src/commands/ext.rs index cdd7b2b..5091fb4 100644 --- a/src/commands/ext.rs +++ b/src/commands/ext.rs @@ -1168,6 +1168,9 @@ fn cleanup_stale_extension_symlinks( // Build a set of expected symlink names (with versions) let mut expected_names = std::collections::HashSet::new(); + // Also track base names without versions for masking logic + let mut non_versioned_base_names = std::collections::HashSet::new(); + for ext in enabled_extensions { let name_with_version = if let Some(ver) = &ext.version { format!("{}-{}", ext.name, ver) @@ -1175,6 +1178,11 @@ fn cleanup_stale_extension_symlinks( ext.name.clone() }; expected_names.insert(name_with_version); + + // Track non-versioned extensions (e.g., HITL mounts) for masking + if ext.version.is_none() { + non_versioned_base_names.insert(ext.name.clone()); + } } // Clean up sysext directory @@ -1187,9 +1195,34 @@ fn cleanup_stale_extension_symlinks( // Remove .raw suffix if present for comparison let name_without_raw = file_name.strip_suffix(".raw").unwrap_or(file_name); - if !expected_names.contains(file_name) + // Check if this symlink should be removed + let should_remove = if !expected_names.contains(file_name) && !expected_names.contains(name_without_raw) { + // Not in expected list, should be removed + true + } else { + // Check if this is a versioned symlink that should be masked by a non-versioned one + // e.g., "myext-1.0.0" should be removed if "myext" (HITL mount) exists + if let Some(last_dash) = name_without_raw.rfind('-') { + let base_name = &name_without_raw[..last_dash]; + let potential_version = &name_without_raw[last_dash + 1..]; + // Check if this looks like a version (contains digits or dots) + if potential_version + .chars() + .any(|c| c.is_ascii_digit() || c == '.') + { + // This is a versioned symlink, check if we have a non-versioned version + non_versioned_base_names.contains(base_name) + } else { + false + } + } else { + false + } + }; + + if should_remove { if let Err(e) = fs::remove_file(&path) { output.progress(&format!( "Warning: Failed to remove stale sysext symlink {}: {}", @@ -1218,9 +1251,34 @@ fn cleanup_stale_extension_symlinks( // Remove .raw suffix if present for comparison let name_without_raw = file_name.strip_suffix(".raw").unwrap_or(file_name); - if !expected_names.contains(file_name) + // Check if this symlink should be removed + let should_remove = if !expected_names.contains(file_name) && !expected_names.contains(name_without_raw) { + // Not in expected list, should be removed + true + } else { + // Check if this is a versioned symlink that should be masked by a non-versioned one + // e.g., "myext-1.0.0" should be removed if "myext" (HITL mount) exists + if let Some(last_dash) = name_without_raw.rfind('-') { + let base_name = &name_without_raw[..last_dash]; + let potential_version = &name_without_raw[last_dash + 1..]; + // Check if this looks like a version (contains digits or dots) + if potential_version + .chars() + .any(|c| c.is_ascii_digit() || c == '.') + { + // This is a versioned symlink, check if we have a non-versioned version + non_versioned_base_names.contains(base_name) + } else { + false + } + } else { + false + } + }; + + if should_remove { if let Err(e) = fs::remove_file(&path) { output.progress(&format!( "Warning: Failed to remove stale confext symlink {}: {}", diff --git a/tests/ext_integration_tests.rs b/tests/ext_integration_tests.rs index a280fce..12e0951 100644 --- a/tests/ext_integration_tests.rs +++ b/tests/ext_integration_tests.rs @@ -2073,3 +2073,328 @@ fn test_stale_symlink_cleanup() { "Should remove stale symlink or show cleanup message" ); } + +#[test] +fn test_hitl_mount_masks_versioned_extensions() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + let hitl_dir = temp_dir.path().join("avocado/hitl"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create a versioned extension (myext-1.0.0) in the regular extensions directory + let versioned_ext_dir = extensions_dir.join("myext-1.0.0"); + fs::create_dir(&versioned_ext_dir).expect("Failed to create versioned extension directory"); + let versioned_release_dir = versioned_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&versioned_release_dir).expect("Failed to create release dir"); + fs::write( + versioned_release_dir.join("extension-release.myext-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable the versioned extension first + let enable_output = run_avocadoctl_with_env(&["enable", "--verbose", "myext-1.0.0"], &test_env); + assert!( + enable_output.status.success(), + "Enable command should succeed" + ); + + // Refresh to create symlinks for the versioned extension (WITHOUT HITL mount yet) + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success(), "First refresh should succeed"); + + let sysext_dir = temp_dir.path().join("test_extensions"); + + // Verify that the versioned symlink was created + assert!( + sysext_dir.join("myext-1.0.0").exists(), + "Versioned symlink (myext-1.0.0) should exist after initial refresh" + ); + + // Now create a HITL extension with the same base name (myext) but no version + fs::create_dir_all(&hitl_dir).expect("Failed to create HITL directory"); + let hitl_ext_dir = hitl_dir.join("myext"); + fs::create_dir(&hitl_ext_dir).expect("Failed to create HITL extension directory"); + let hitl_release_dir = hitl_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&hitl_release_dir).expect("Failed to create HITL release dir"); + fs::write( + hitl_release_dir.join("extension-release.myext"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write HITL release file"); + + // Refresh again - this should detect the HITL mount and remove the versioned symlink + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success(), "Second refresh should succeed"); + let stdout2 = String::from_utf8_lossy(&refresh2.stdout); + + // Verify that the versioned symlink was removed (masked by HITL) + assert!( + !sysext_dir.join("myext-1.0.0").exists(), + "Versioned symlink (myext-1.0.0) should be removed when HITL mount (myext) exists" + ); + + // Verify that the non-versioned HITL symlink exists + assert!( + sysext_dir.join("myext").exists(), + "HITL symlink (myext) should exist" + ); + + // Check for cleanup message in verbose output + assert!( + stdout2.contains("Removed stale") || stdout2.contains("myext"), + "Should mention cleanup or the extension name in verbose output" + ); +} + +#[test] +fn test_hitl_mount_masks_multiple_versions() { + // Test that HITL mount masks multiple different versions of the same extension + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + let hitl_dir = temp_dir.path().join("avocado/hitl"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create multiple versioned extensions (myext-1.0.0 and myext-2.0.0) + for version in &["1.0.0", "2.0.0"] { + let ext_name = format!("myext-{}", version); + let versioned_ext_dir = extensions_dir.join(&ext_name); + fs::create_dir(&versioned_ext_dir).expect("Failed to create versioned extension directory"); + let versioned_release_dir = versioned_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&versioned_release_dir).expect("Failed to create release dir"); + fs::write( + versioned_release_dir.join(format!("extension-release.{}", ext_name)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable both versioned extensions + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "myext-1.0.0", "myext-2.0.0"], + &test_env, + ); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Refresh to create symlinks + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success(), "First refresh should succeed"); + + let sysext_dir = temp_dir.path().join("test_extensions"); + + // Verify both versioned symlinks exist (only one would be active, but both should be in os-releases) + // Note: Only the last enabled one should actually be symlinked since they have the same base name + // and the extension_map uses the base name as key + assert!( + sysext_dir.join("myext-1.0.0").exists() || sysext_dir.join("myext-2.0.0").exists(), + "At least one versioned symlink should exist" + ); + + // Create HITL mount + fs::create_dir_all(&hitl_dir).expect("Failed to create HITL directory"); + let hitl_ext_dir = hitl_dir.join("myext"); + fs::create_dir(&hitl_ext_dir).expect("Failed to create HITL extension directory"); + let hitl_release_dir = hitl_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&hitl_release_dir).expect("Failed to create HITL release dir"); + fs::write( + hitl_release_dir.join("extension-release.myext"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write HITL release file"); + + // Refresh with HITL mount + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success(), "Second refresh should succeed"); + + // Verify ALL versioned symlinks are removed + assert!( + !sysext_dir.join("myext-1.0.0").exists(), + "myext-1.0.0 should be masked by HITL mount" + ); + assert!( + !sysext_dir.join("myext-2.0.0").exists(), + "myext-2.0.0 should be masked by HITL mount" + ); + assert!( + sysext_dir.join("myext").exists(), + "HITL symlink should exist" + ); +} + +#[test] +fn test_hitl_mount_only_masks_same_base_name() { + // Test that HITL mount for "myext" doesn't mask "otherext-1.0.0" + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + let hitl_dir = temp_dir.path().join("avocado/hitl"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create two different extensions + for (name, version) in &[("myext", "1.0.0"), ("otherext", "2.0.0")] { + let ext_name = format!("{}-{}", name, version); + let ext_dir = extensions_dir.join(&ext_name); + fs::create_dir(&ext_dir).expect("Failed to create extension directory"); + let release_dir = ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&release_dir).expect("Failed to create release dir"); + fs::write( + release_dir.join(format!("extension-release.{}", ext_name)), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + } + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable both extensions + let enable_output = run_avocadoctl_with_env( + &["enable", "--verbose", "myext-1.0.0", "otherext-2.0.0"], + &test_env, + ); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Refresh to create symlinks + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh1.status.success(), "First refresh should succeed"); + + let sysext_dir = temp_dir.path().join("test_extensions"); + + // Verify both symlinks exist + assert!( + sysext_dir.join("myext-1.0.0").exists(), + "myext-1.0.0 should exist" + ); + assert!( + sysext_dir.join("otherext-2.0.0").exists(), + "otherext-2.0.0 should exist" + ); + + // Create HITL mount for myext only + fs::create_dir_all(&hitl_dir).expect("Failed to create HITL directory"); + let hitl_ext_dir = hitl_dir.join("myext"); + fs::create_dir(&hitl_ext_dir).expect("Failed to create HITL extension directory"); + let hitl_release_dir = hitl_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&hitl_release_dir).expect("Failed to create HITL release dir"); + fs::write( + hitl_release_dir.join("extension-release.myext"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write HITL release file"); + + // Refresh with HITL mount + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!(refresh2.status.success(), "Second refresh should succeed"); + + // Verify myext-1.0.0 is masked but otherext-2.0.0 remains + assert!( + !sysext_dir.join("myext-1.0.0").exists(), + "myext-1.0.0 should be masked" + ); + assert!(sysext_dir.join("myext").exists(), "HITL myext should exist"); + assert!( + sysext_dir.join("otherext-2.0.0").exists(), + "otherext-2.0.0 should NOT be masked (different base name)" + ); +} + +#[test] +fn test_hitl_mount_removal_restores_versioned() { + // Test that removing HITL mount allows the versioned extension to be used again + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let extensions_dir = temp_dir.path().join("extensions"); + let hitl_dir = temp_dir.path().join("avocado/hitl"); + fs::create_dir_all(&extensions_dir).expect("Failed to create extensions directory"); + + // Create a versioned extension + let versioned_ext_dir = extensions_dir.join("myext-1.0.0"); + fs::create_dir(&versioned_ext_dir).expect("Failed to create versioned extension directory"); + let versioned_release_dir = versioned_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&versioned_release_dir).expect("Failed to create release dir"); + fs::write( + versioned_release_dir.join("extension-release.myext-1.0.0"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write release file"); + + let test_env = [ + ("AVOCADO_EXTENSIONS_PATH", extensions_dir.to_str().unwrap()), + ("AVOCADO_TEST_MODE", "1"), + ("TMPDIR", temp_dir.path().to_str().unwrap()), + ]; + + // Enable the versioned extension + let enable_output = run_avocadoctl_with_env(&["enable", "--verbose", "myext-1.0.0"], &test_env); + assert!(enable_output.status.success(), "Enable should succeed"); + + // Create and use HITL mount + fs::create_dir_all(&hitl_dir).expect("Failed to create HITL directory"); + let hitl_ext_dir = hitl_dir.join("myext"); + fs::create_dir(&hitl_ext_dir).expect("Failed to create HITL extension directory"); + let hitl_release_dir = hitl_ext_dir.join("usr/lib/extension-release.d"); + fs::create_dir_all(&hitl_release_dir).expect("Failed to create HITL release dir"); + fs::write( + hitl_release_dir.join("extension-release.myext"), + "ID=avocado\nVERSION_ID=1.0", + ) + .expect("Failed to write HITL release file"); + + // Refresh with HITL + let (refresh1, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!( + refresh1.status.success(), + "Refresh with HITL should succeed" + ); + + let sysext_dir = temp_dir.path().join("test_extensions"); + assert!( + sysext_dir.join("myext").exists(), + "HITL symlink should exist" + ); + assert!( + !sysext_dir.join("myext-1.0.0").exists(), + "Versioned should be masked" + ); + + // Remove HITL mount + fs::remove_dir_all(&hitl_ext_dir).expect("Failed to remove HITL extension"); + + // Refresh without HITL + let (refresh2, _) = + run_avocadoctl_with_isolated_env(&["ext", "refresh", "--verbose"], &test_env); + assert!( + refresh2.status.success(), + "Refresh without HITL should succeed" + ); + + // Verify versioned extension is restored + assert!( + !sysext_dir.join("myext").exists(), + "HITL symlink should be removed" + ); + assert!( + sysext_dir.join("myext-1.0.0").exists(), + "Versioned symlink should be restored" + ); +}