1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
#![allow(clippy::unwrap_used)] // Tests can use unwrap() for simplicity
#![allow(clippy::expect_used)]
//! Property-Based Tests for Fix Safety Taxonomy
//!
//! EXTREME TDD + FAST Validation:
//! - Property testing: Verify invariants hold for all inputs
//! - Fuzz testing: Generate random valid bash scripts
//! - AST verification: Ensure fixes preserve valid syntax
//! - Safety verification: Ensure safety levels are respected
//! - Throughput: Measure performance under property testing load
//!
//! Using proptest for generative testing (100+ cases per property)
use bashrs::linter::autofix::{apply_fixes, FixOptions};
use bashrs::linter::rules::{det001, idem001, idem002, lint_shell, sc2086};
use proptest::prelude::*;
use std::process::Command;
// ============================================================================
// PROPERTY 1: SAFE fixes are truly safe (idempotent + syntax-preserving)
// ============================================================================
/// Property: Applying SAFE fixes twice produces identical output
#[test]
fn prop_safe_fixes_are_idempotent() {
proptest!(|(var_name in "[a-zA-Z_][a-zA-Z0-9_]{0,10}")| {
// Generate bash script with unquoted variable
let script = format!("echo ${}", var_name);
// Apply fixes once
let result1 = sc2086::check(&script);
let options = FixOptions {
create_backup: false,
dry_run: false,
backup_suffix: String::new(),
apply_assumptions: false, // SAFE only
output_path: None,
};
let fixed1 = apply_fixes(&script, &result1, &options)
.expect("First fix should succeed");
// Apply fixes twice (on already-fixed code)
if let Some(ref fixed_code) = fixed1.modified_source {
let result2 = sc2086::check(fixed_code);
let fixed2 = apply_fixes(fixed_code, &result2, &options)
.expect("Second fix should succeed");
// Property: fixed1 == fixed2 (idempotent)
prop_assert_eq!(
fixed1.modified_source,
fixed2.modified_source,
"SAFE fixes must be idempotent"
);
}
});
}
/// Property: SAFE fixes preserve valid bash syntax
#[test]
fn prop_safe_fixes_preserve_syntax() {
proptest!(|(var_name in "[a-zA-Z_][a-zA-Z0-9_]{0,10}")| {
let script = format!("#!/bin/bash\necho ${}\nls ${}", var_name, var_name);
let result = sc2086::check(&script);
let options = FixOptions::default();
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Verify syntax with shellcheck
let syntax_check = Command::new("shellcheck")
.arg("-s")
.arg("bash")
.arg("-")
.arg("--norc")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
// Property: Fixed code has valid syntax (if shellcheck available)
if let Ok(mut child) = syntax_check {
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(fixed_code.as_bytes());
}
// We don't assert on exit code because shellcheck may find other issues,
// but we verify no parse errors by checking the code doesn't crash shellcheck
let _ = child.wait();
// If we got here, shellcheck didn't crash on the syntax
prop_assert!(true);
}
}
});
}
/// Property: SAFE fixes only quote variables, don't change semantics
#[test]
fn prop_safe_fixes_only_add_quotes() {
proptest!(|(var_name in "[a-zA-Z_][a-zA-Z0-9_]{0,10}")| {
let script = format!("echo ${}", var_name);
let result = sc2086::check(&script);
let options = FixOptions::default();
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Property: Fixed code contains the variable name
prop_assert!(
fixed_code.contains(&var_name),
"SAFE fix must preserve variable name"
);
// Property: Fixed code adds quotes around variable
let quoted = format!("\"${}\"", var_name);
prop_assert!(
fixed_code.contains("ed),
"SAFE fix must add quotes around variable"
);
}
});
}
// ============================================================================
// PROPERTY 2: SAFE-WITH-ASSUMPTIONS requires explicit opt-in
// ============================================================================
/// Property: IDEM001 (mkdir -p) NOT applied without --fix-assumptions
#[test]
fn prop_idem001_not_applied_by_default() {
proptest!(|(dir_name in "/tmp/[a-z]{5,10}")| {
let script = format!("mkdir {}", dir_name);
let result = idem001::check(&script);
let options = FixOptions {
create_backup: false,
dry_run: false,
backup_suffix: String::new(),
apply_assumptions: false, // Default: no assumptions
output_path: None,
};
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Property: Without --fix-assumptions, mkdir stays as-is
prop_assert!(
!fixed_code.contains("mkdir -p"),
"SAFE-WITH-ASSUMPTIONS fix must NOT apply without flag"
);
prop_assert_eq!(
&script, fixed_code,
"Code should be unchanged without --fix-assumptions"
);
}
});
}
/// Property: IDEM001 (mkdir -p) IS applied with --fix-assumptions
#[test]
fn prop_idem001_applied_with_assumptions() {
proptest!(|(dir_name in "/tmp/[a-z]{5,10}")| {
let script = format!("mkdir {}", dir_name);
let result = idem001::check(&script);
let options = FixOptions {
create_backup: false,
dry_run: false,
backup_suffix: String::new(),
apply_assumptions: true, // Opt-in to assumptions
output_path: None,
};
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Property: With --fix-assumptions, mkdir becomes mkdir -p
prop_assert!(
fixed_code.contains("mkdir -p"),
"SAFE-WITH-ASSUMPTIONS fix must apply with flag"
);
}
});
}
/// Property: IDEM002 (rm -f) NOT applied without --fix-assumptions
#[test]
fn prop_idem002_not_applied_by_default() {
proptest!(|(file_name in "/tmp/[a-z]{5,10}\\.txt")| {
let script = format!("rm {}", file_name);
let result = idem002::check(&script);
let options = FixOptions {
create_backup: false,
dry_run: false,
backup_suffix: String::new(),
apply_assumptions: false,
output_path: None,
};
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Property: Without --fix-assumptions, rm stays as-is
prop_assert!(
!fixed_code.contains("rm -f"),
"SAFE-WITH-ASSUMPTIONS fix must NOT apply without flag"
);
}
});
}
// ============================================================================
// PROPERTY 3: UNSAFE fixes are NEVER auto-applied
// ============================================================================
/// Property: DET001 ($RANDOM) is NEVER auto-fixed
#[test]
fn prop_det001_never_autofixed() {
proptest!(|(var_name in "[A-Z_]{3,10}")| {
let script = format!("{}=$RANDOM", var_name);
let result = det001::check(&script);
// Try with all flag combinations
for apply_assumptions in [false, true] {
let options = FixOptions {
create_backup: false,
dry_run: false,
backup_suffix: String::new(),
apply_assumptions,
output_path: None,
};
let fixed = apply_fixes(&script, &result, &options)
.expect("Fix should succeed");
if let Some(ref fixed_code) = fixed.modified_source {
// Property: $RANDOM must remain unchanged (UNSAFE)
prop_assert!(
fixed_code.contains("$RANDOM"),
"UNSAFE fix must NEVER auto-apply (apply_assumptions={})",
apply_assumptions
);
}
}
});
}
/// Property: UNSAFE fixes provide suggestions (not replacements)
#[test]
fn prop_unsafe_fixes_provide_suggestions() {
proptest!(|(var_name in "[A-Z_]{3,10}")| {
let script = format!("{}=$RANDOM", var_name);
let result = det001::check(&script);
// Property: Diagnostic has a fix
prop_assert!(!result.diagnostics.is_empty());
if let Some(ref fix) = result.diagnostics[0].fix {
// Property: Fix is marked as UNSAFE
prop_assert!(fix.is_unsafe());
// Property: UNSAFE fix has empty replacement
prop_assert_eq!(
&fix.replacement, "",
"UNSAFE fix must have empty replacement"
);
// Property: UNSAFE fix provides suggestions
prop_assert!(
!fix.suggested_alternatives.is_empty(),
"UNSAFE fix must provide suggestions"
);
// Property: At least 2 suggestions
prop_assert!(
fix.suggested_alternatives.len() >= 2,
"UNSAFE fix should provide multiple alternatives"
);
}
});
}
// ============================================================================
// PROPERTY 4: Fix safety levels are correctly classified
// ============================================================================
/// Property: SC2086 fixes are always SAFE
#[test]
fn prop_sc2086_is_always_safe() {
proptest!(|(var_name in "[a-zA-Z_][a-zA-Z0-9_]{0,10}")| {
let script = format!("echo ${}", var_name);
let result = sc2086::check(&script);
prop_assert!(!result.diagnostics.is_empty());
if let Some(ref fix) = result.diagnostics[0].fix {
// Property: SC2086 fix is SAFE
prop_assert!(fix.is_safe());
prop_assert!(!fix.is_unsafe());
// Property: SAFE fix has non-empty replacement
prop_assert!(
!fix.replacement.is_empty(),
"SAFE fix must have replacement"
);
// Property: SAFE fix has no assumptions
prop_assert!(
fix.assumptions.is_empty(),
"SAFE fix should have no assumptions"
);
}
});
}
/// Property: IDEM001 fixes are SAFE-WITH-ASSUMPTIONS
#[test]
fn prop_idem001_is_safe_with_assumptions() {
proptest!(|(dir_name in "/[a-z]{5,10}")| {
let script = format!("mkdir {}", dir_name);
let result = idem001::check(&script);
prop_assert!(!result.diagnostics.is_empty());
if let Some(ref fix) = result.diagnostics[0].fix {
// Property: IDEM001 fix is SAFE-WITH-ASSUMPTIONS
prop_assert!(fix.is_safe_with_assumptions());
prop_assert!(!fix.is_safe());
prop_assert!(!fix.is_unsafe());
// Property: Has documented assumptions
prop_assert!(
!fix.assumptions.is_empty(),
"SAFE-WITH-ASSUMPTIONS must document assumptions"
);
}
});
}
// ============================================================================
// PROPERTY 5: Performance properties (throughput)
// ============================================================================
/// Property: Linting completes quickly (< 100ms for typical scripts)
/// NOTE: Marked ignore due to timing flakiness on CI/slow machines
#[test]
#[ignore]
fn prop_linting_performance() {
proptest!(|(
var_count in 1..10usize,
var_names in prop::collection::vec("[a-z]{3,8}", 1..10)
)| {
// Generate script with multiple variables
let mut script = String::from("#!/bin/bash\n");
for (_i, name) in var_names.iter().enumerate().take(var_count) {
script.push_str(&format!("echo ${}\n", name));
}
// Measure linting performance
let start = std::time::Instant::now();
let result = lint_shell(&script);
let duration = start.elapsed();
// Property: Linting completes in < 100ms for scripts with < 10 variables
prop_assert!(
duration.as_millis() < 100,
"Linting should complete in < 100ms (took {}ms)",
duration.as_millis()
);
// Property: Result is valid (may have multiple diagnostics per variable)
// Note: Some variable names might trigger multiple rules (e.g., "scp" could match patterns)
prop_assert!(result.diagnostics.len() < var_count * 5);
});
}
// ============================================================================
// PROPERTY 6: No false positives
// ============================================================================
/// Property: Already-quoted variables don't trigger SC2086
#[test]
fn prop_no_false_positives_quoted_vars() {
proptest!(|(var_name in "[a-zA-Z_][a-zA-Z0-9_]{0,10}")| {
// Script with already-quoted variable
let script = format!("echo \"${}\"", var_name);
let result = sc2086::check(&script);
// Property: No diagnostics for already-quoted variables
prop_assert_eq!(
result.diagnostics.len(), 0,
"Should not flag already-quoted variables"
);
});
}
/// Property: mkdir -p doesn't trigger IDEM001
#[test]
fn prop_no_false_positives_mkdir_p() {
proptest!(|(dir_name in "/[a-z]{5,10}")| {
// Script with already-idempotent mkdir
let script = format!("mkdir -p {}", dir_name);
let result = idem001::check(&script);
// Property: No diagnostics for mkdir -p
prop_assert_eq!(
result.diagnostics.len(), 0,
"Should not flag mkdir -p"
);
});
}