veridian_kernel/pkg/testing.rs
1//! Package Testing and Security Scanning
2//!
3//! Provides automated package validation through test definition, execution
4//! framework, and pre-install security scanning. The test runner validates
5//! test definitions; actual process spawning is deferred to user-space.
6//! The security scanner checks package file paths and requested capabilities
7//! against known-suspicious patterns before installation.
8//!
9//! NOTE: Many types in this module are forward declarations for user-space
10//! APIs. They will be exercised when user-space process execution is
11//! functional. See TODO(user-space) markers for specific activation points.
12
13// User-space API forward declarations -- see NOTE above
14
15#[cfg(feature = "alloc")]
16extern crate alloc;
17
18#[cfg(feature = "alloc")]
19use alloc::{string::String, vec::Vec};
20
21// ============================================================================
22// Package Test Framework
23// ============================================================================
24
25/// Classification of package tests.
26#[cfg(feature = "alloc")]
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum TestType {
29 /// Quick smoke tests to verify basic functionality.
30 Smoke,
31 /// Unit tests for individual components.
32 Unit,
33 /// Integration tests across components.
34 Integration,
35}
36
37#[cfg(feature = "alloc")]
38impl TestType {
39 /// Parse a test type from a string identifier.
40 pub fn parse(s: &str) -> Self {
41 match s {
42 "smoke" => Self::Smoke,
43 "unit" => Self::Unit,
44 "integration" => Self::Integration,
45 _ => Self::Unit,
46 }
47 }
48
49 /// Return a human-readable name.
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Smoke => "smoke",
53 Self::Unit => "unit",
54 Self::Integration => "integration",
55 }
56 }
57}
58
59/// Definition of a single package test.
60#[cfg(feature = "alloc")]
61#[derive(Debug, Clone)]
62pub struct PackageTest {
63 /// Name of the test.
64 pub test_name: String,
65 /// Classification of the test.
66 pub test_type: TestType,
67 /// Command to execute for this test.
68 pub command: String,
69 /// Maximum execution time in milliseconds.
70 pub timeout_ms: u64,
71 /// Expected process exit code (0 = success).
72 pub expected_exit: i32,
73}
74
75#[cfg(feature = "alloc")]
76impl PackageTest {
77 /// Create a new package test definition.
78 pub fn new(
79 test_name: String,
80 test_type: TestType,
81 command: String,
82 timeout_ms: u64,
83 expected_exit: i32,
84 ) -> Self {
85 Self {
86 test_name,
87 test_type,
88 command,
89 timeout_ms,
90 expected_exit,
91 }
92 }
93
94 /// Validate that this test definition is well-formed.
95 pub fn validate(&self) -> Result<(), crate::error::KernelError> {
96 if self.test_name.is_empty() {
97 return Err(crate::error::KernelError::InvalidArgument {
98 name: "test_name",
99 value: "empty",
100 });
101 }
102 if self.command.is_empty() {
103 return Err(crate::error::KernelError::InvalidArgument {
104 name: "command",
105 value: "empty",
106 });
107 }
108 if self.timeout_ms == 0 {
109 return Err(crate::error::KernelError::InvalidArgument {
110 name: "timeout_ms",
111 value: "zero",
112 });
113 }
114 Ok(())
115 }
116}
117
118/// Result of executing a single package test.
119#[cfg(feature = "alloc")]
120#[derive(Debug, Clone)]
121pub struct TestResult {
122 /// Name of the test that was executed.
123 pub test_name: String,
124 /// Whether the test passed.
125 pub passed: bool,
126 /// Execution duration in milliseconds.
127 pub duration_ms: u64,
128 /// Exit code returned by the test process.
129 pub exit_code: i32,
130 /// Standard output captured from the test.
131 pub stdout: String,
132 /// Standard error captured from the test.
133 pub stderr: String,
134}
135
136#[cfg(feature = "alloc")]
137impl TestResult {
138 /// Create a placeholder result for a test that has not yet been executed.
139 ///
140 /// Used when actual process spawning is deferred to user-space.
141 fn deferred(test_name: &str) -> Self {
142 Self {
143 test_name: String::from(test_name),
144 passed: true,
145 duration_ms: 0,
146 exit_code: 0,
147 stdout: String::from("TODO(user-space): test execution deferred"),
148 stderr: String::new(),
149 }
150 }
151}
152
153/// Test runner that manages and executes package tests.
154///
155/// Actual process spawning is deferred to user-space. The runner validates
156/// test definitions and creates placeholder results.
157#[cfg(feature = "alloc")]
158pub struct TestRunner {
159 /// Registered test definitions.
160 tests: Vec<PackageTest>,
161 /// Accumulated test results.
162 results: Vec<TestResult>,
163}
164
165#[cfg(feature = "alloc")]
166impl TestRunner {
167 /// Create a new empty test runner.
168 pub fn new() -> Self {
169 Self {
170 tests: Vec::new(),
171 results: Vec::new(),
172 }
173 }
174
175 /// Add a test definition to the runner.
176 ///
177 /// Returns an error if the test definition is invalid.
178 pub fn add_test(&mut self, test: PackageTest) -> Result<(), crate::error::KernelError> {
179 test.validate()?;
180 self.tests.push(test);
181 Ok(())
182 }
183
184 /// Run all registered tests and return results.
185 ///
186 /// NOTE: Actual process spawning is deferred to user-space. This method
187 /// validates test definitions and creates placeholder `TestResult` entries
188 /// with `TODO(user-space)` markers. When user-space process execution is
189 /// available, this will spawn test processes and capture real output.
190 pub fn run_all(&mut self) -> Vec<TestResult> {
191 self.results.clear();
192
193 for test in &self.tests {
194 // TODO(user-space): Spawn process from test.command, capture
195 // stdout/stderr, enforce test.timeout_ms, and compare exit code
196 // against test.expected_exit.
197 let result = TestResult::deferred(&test.test_name);
198 self.results.push(result);
199 }
200
201 self.results.clone()
202 }
203
204 /// Run a single test by name and return its result.
205 ///
206 /// Returns `None` if no test with the given name is registered.
207 pub fn run_single(&mut self, name: &str) -> Option<TestResult> {
208 let test = self.tests.iter().find(|t| t.test_name == name)?;
209 // TODO(user-space): Spawn process from test.command
210 let result = TestResult::deferred(&test.test_name);
211 self.results.push(result.clone());
212 Some(result)
213 }
214
215 /// Return the number of registered tests.
216 pub fn test_count(&self) -> usize {
217 self.tests.len()
218 }
219
220 /// Return accumulated test results.
221 pub fn results(&self) -> &[TestResult] {
222 &self.results
223 }
224
225 /// Count how many tests passed.
226 pub fn pass_count(&self) -> usize {
227 self.results.iter().filter(|r| r.passed).count()
228 }
229
230 /// Count how many tests failed.
231 pub fn fail_count(&self) -> usize {
232 self.results.iter().filter(|r| !r.passed).count()
233 }
234}
235
236#[cfg(feature = "alloc")]
237impl Default for TestRunner {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243/// Run all tests defined for a package.
244///
245/// Looks up test definitions for the given package name and executes them
246/// through a `TestRunner`. Returns an empty list if the package has no tests.
247#[cfg(feature = "alloc")]
248pub fn run_package_tests(package: &str) -> Vec<TestResult> {
249 // TODO(user-space): Load test definitions from package metadata or
250 // test manifest file at /usr/local/packages/<package>/tests.toml.
251 // For now, create a default smoke test.
252 let mut runner = TestRunner::new();
253
254 let smoke_test = PackageTest::new(
255 alloc::format!("{}-smoke", package),
256 TestType::Smoke,
257 alloc::format!("/usr/local/packages/{}/bin/test-smoke", package),
258 5000,
259 0,
260 );
261
262 if runner.add_test(smoke_test).is_ok() {
263 runner.run_all()
264 } else {
265 Vec::new()
266 }
267}
268
269// ============================================================================
270// Package Security Scanner
271// ============================================================================
272
273/// Severity level for package security scan findings.
274///
275/// Distinct from `repository::Severity` which is used for repository-level
276/// vulnerability tracking. This type is for pre-install package scanning.
277#[cfg(feature = "alloc")]
278#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
279pub enum ScanSeverity {
280 /// Low-risk finding (informational).
281 Low,
282 /// Medium-risk finding (review recommended).
283 Medium,
284 /// High-risk finding (should be addressed).
285 High,
286 /// Critical-risk finding (blocks installation).
287 Critical,
288}
289
290#[cfg(feature = "alloc")]
291impl ScanSeverity {
292 /// Return a human-readable label.
293 pub fn as_str(self) -> &'static str {
294 match self {
295 Self::Low => "low",
296 Self::Medium => "medium",
297 Self::High => "high",
298 Self::Critical => "critical",
299 }
300 }
301}
302
303/// Classification of scan pattern types.
304#[cfg(feature = "alloc")]
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ScanPatternType {
307 /// File path that accesses sensitive system locations.
308 SuspiciousPath,
309 /// Capability request that is excessively broad.
310 ExcessiveCapability,
311 /// File whose hash matches a known-bad sample.
312 KnownBadHash,
313 /// Code pattern that is potentially unsafe.
314 UnsafePattern,
315}
316
317/// A pattern used to detect suspicious content in a package.
318#[cfg(feature = "alloc")]
319#[derive(Debug, Clone)]
320pub struct ScanPattern {
321 /// Human-readable name for this pattern.
322 pub name: String,
323 /// What kind of pattern this is.
324 pub pattern_type: ScanPatternType,
325 /// Description of why this pattern is suspicious.
326 pub description: String,
327 /// Severity if this pattern is matched.
328 pub severity: ScanSeverity,
329}
330
331/// A security finding produced by the package scanner.
332#[cfg(feature = "alloc")]
333#[derive(Debug, Clone)]
334pub struct SecurityFinding {
335 /// Severity of the finding.
336 pub severity: ScanSeverity,
337 /// Description of the issue.
338 pub description: String,
339 /// File path that triggered the finding (empty for capability findings).
340 pub file_path: String,
341 /// Name of the pattern that was matched.
342 pub pattern_name: String,
343}
344
345/// Pre-install package security scanner.
346///
347/// Scans package file paths and requested capabilities against a set of
348/// suspicious patterns before the package is installed. This is distinct
349/// from `repository::SecurityScanner` which operates at the repository level.
350#[cfg(feature = "alloc")]
351pub struct PackageSecurityScanner {
352 /// Registered scan patterns.
353 patterns: Vec<ScanPattern>,
354}
355
356#[cfg(feature = "alloc")]
357impl PackageSecurityScanner {
358 /// Create a new scanner pre-loaded with default suspicious patterns.
359 pub fn new() -> Self {
360 let mut scanner = Self {
361 patterns: Vec::new(),
362 };
363 scanner.load_default_patterns();
364 scanner
365 }
366
367 /// Register an additional scan pattern.
368 pub fn add_pattern(&mut self, pattern: ScanPattern) {
369 self.patterns.push(pattern);
370 }
371
372 /// Return the number of registered patterns.
373 pub fn pattern_count(&self) -> usize {
374 self.patterns.len()
375 }
376
377 /// Scan a list of file paths against suspicious-path patterns.
378 ///
379 /// Checks each file path against all `ScanPatternType::SuspiciousPath`
380 /// and `ScanPatternType::UnsafePattern` patterns.
381 pub fn scan_paths(&self, file_paths: &[&str]) -> Vec<SecurityFinding> {
382 let mut findings = Vec::new();
383
384 for path in file_paths {
385 for pattern in &self.patterns {
386 let matches = match pattern.pattern_type {
387 ScanPatternType::SuspiciousPath => path.contains(pattern.name.as_str()),
388 ScanPatternType::UnsafePattern => path.contains(pattern.name.as_str()),
389 _ => false,
390 };
391
392 if matches {
393 findings.push(SecurityFinding {
394 severity: pattern.severity,
395 description: pattern.description.clone(),
396 file_path: String::from(*path),
397 pattern_name: pattern.name.clone(),
398 });
399 }
400 }
401 }
402
403 findings
404 }
405
406 /// Scan requested capabilities against excessive-capability patterns.
407 ///
408 /// Checks each requested capability against all
409 /// `ScanPatternType::ExcessiveCapability` patterns.
410 pub fn scan_capabilities(&self, requested_caps: &[&str]) -> Vec<SecurityFinding> {
411 let mut findings = Vec::new();
412
413 for cap in requested_caps {
414 for pattern in &self.patterns {
415 if pattern.pattern_type != ScanPatternType::ExcessiveCapability {
416 continue;
417 }
418 if *cap == pattern.name.as_str() {
419 findings.push(SecurityFinding {
420 severity: pattern.severity,
421 description: pattern.description.clone(),
422 file_path: String::new(),
423 pattern_name: pattern.name.clone(),
424 });
425 }
426 }
427 }
428
429 findings
430 }
431
432 /// Scan file hashes against known-bad hash patterns.
433 ///
434 /// `file_hashes` is a list of `(file_path, hex_hash)` pairs.
435 pub fn scan_hashes(&self, file_hashes: &[(&str, &str)]) -> Vec<SecurityFinding> {
436 let mut findings = Vec::new();
437
438 for (path, hash) in file_hashes {
439 for pattern in &self.patterns {
440 if pattern.pattern_type != ScanPatternType::KnownBadHash {
441 continue;
442 }
443 if *hash == pattern.name.as_str() {
444 findings.push(SecurityFinding {
445 severity: pattern.severity,
446 description: pattern.description.clone(),
447 file_path: String::from(*path),
448 pattern_name: pattern.name.clone(),
449 });
450 }
451 }
452 }
453
454 findings
455 }
456
457 /// Check if any finding is at or above the given severity threshold.
458 pub fn has_findings_at_severity(
459 findings: &[SecurityFinding],
460 min_severity: ScanSeverity,
461 ) -> bool {
462 findings.iter().any(|f| f.severity >= min_severity)
463 }
464
465 /// Populate the scanner with well-known suspicious patterns.
466 fn load_default_patterns(&mut self) {
467 // Suspicious file paths (high severity)
468 let suspicious_paths: &[(&str, &str)] = &[
469 ("/etc/shadow", "Access to password shadow file"),
470 ("/dev/mem", "Direct physical memory access"),
471 ("/proc/kcore", "Kernel memory image access"),
472 ("/dev/kmem", "Kernel memory device access"),
473 ];
474 for (path, desc) in suspicious_paths {
475 self.patterns.push(ScanPattern {
476 name: String::from(*path),
477 pattern_type: ScanPatternType::SuspiciousPath,
478 description: String::from(*desc),
479 severity: ScanSeverity::High,
480 });
481 }
482
483 // Excessive capability requests (medium-high severity)
484 let dangerous_caps: &[(&str, &str, ScanSeverity)] = &[
485 (
486 "CAP_SYS_ADMIN",
487 "Broad administrative capability",
488 ScanSeverity::High,
489 ),
490 (
491 "CAP_NET_RAW",
492 "Raw network socket capability",
493 ScanSeverity::Medium,
494 ),
495 (
496 "CAP_SYS_RAWIO",
497 "Raw I/O port access capability",
498 ScanSeverity::High,
499 ),
500 ];
501 for (cap, desc, severity) in dangerous_caps {
502 self.patterns.push(ScanPattern {
503 name: String::from(*cap),
504 pattern_type: ScanPatternType::ExcessiveCapability,
505 description: String::from(*desc),
506 severity: *severity,
507 });
508 }
509
510 // Unsafe permission patterns (medium severity)
511 let unsafe_patterns: &[(&str, &str)] = &[
512 ("setuid", "Setuid binary detected"),
513 ("world-writable", "World-writable file detected"),
514 ];
515 for (pat, desc) in unsafe_patterns {
516 self.patterns.push(ScanPattern {
517 name: String::from(*pat),
518 pattern_type: ScanPatternType::UnsafePattern,
519 description: String::from(*desc),
520 severity: ScanSeverity::Medium,
521 });
522 }
523 }
524}
525
526#[cfg(feature = "alloc")]
527impl Default for PackageSecurityScanner {
528 fn default() -> Self {
529 Self::new()
530 }
531}