use crate::linter::timestamp_flow::{analyze, SinkClass, TimestampUse};
use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
const REMEDY: &str = "Derive it from SOURCE_DATE_EPOCH: \
BUILD_DATE=$(date -u -d \"@${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct)}\" +%Y%m%d)";
const LOG_ADVICE: &str = "If it is only for logging, send it to an append-only sink \
(>>, tee -a, logger), or suppress with `# bashrs disable-line=DET002`.";
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for u in analyze(source) {
if u.class != SinkClass::Benign {
result.add(build_diagnostic(&u));
}
}
result
}
fn build_diagnostic(u: &TimestampUse) -> Diagnostic {
let span = Span::new(u.line, u.col, u.line, u.col + u.len);
Diagnostic::new("DET002", Severity::Error, message_for(u), span).with_fix(det002_fix())
}
fn message_for(u: &TimestampUse) -> String {
match (u.class, u.sink_line, u.sink_text.as_deref(), u.var.as_deref()) {
(SinkClass::Reproducible, Some(l), Some(t), _) => format!(
"Timestamp reaches reproducible output at line {l}: `{}` - the artifact's name or \
contents change on every run. {REMEDY}",
elide(t, 60)
),
(_, _, _, Some(v)) => format!(
"Timestamp captured in `${v}`; its destination is not provably reproducible. \
If it names or fills a build artifact: {REMEDY}. {LOG_ADVICE}"
),
_ => format!(
"Timestamp used here; its destination is not provably reproducible. \
{REMEDY} {LOG_ADVICE}"
),
}
}
fn elide(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let head: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{head}...")
}
fn det002_fix() -> Fix {
Fix::new_unsafe(vec![
format!("Reproducible builds: {REMEDY} - reading SOURCE_DATE_EPOCH clears DET002"),
"Use the release version: RELEASE=\"release-${VERSION}\"".to_string(),
"Use the commit: RELEASE=\"release-$(git rev-parse --short HEAD)\"".to_string(),
"Send the timestamp to a log, not an artifact: `... >> \"$LOG_FILE\"`, \
`... | tee -a \"$LOG_FILE\"`, or `logger \"...\"`"
.to_string(),
"Suppress with rationale: # bashrs disable-line=DET002".to_string(),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_DET002_detects_date_epoch() {
let script = "RELEASE=\"release-$(date +%s)\"";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "DET002");
assert_eq!(diag.severity, Severity::Error);
}
#[test]
fn test_DET002_detects_date_command_sub() {
let script = "BUILD_ID=$(date +%Y%m%d)";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_DET002_detects_backtick_date() {
let script = "TIMESTAMP=`date`";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_DET002_provides_fix() {
let script = "ID=$(date +%s)";
let result = check(script);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, "");
assert!(fix.is_unsafe());
assert!(!fix.suggested_alternatives.is_empty());
assert!(fix.suggested_alternatives.len() >= 3);
}
#[test]
fn test_DET002_no_false_positive() {
let script = "RELEASE=\"release-${VERSION}\"";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_DET002_allows_intentional_timestamp_for_benchmarks() {
let script = r#"#!/bin/bash
# Intentional: timestamp for result tracking
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
RESULT_FILE="benchmarks/results/baseline_$TIMESTAMP.md"
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Intentionally marked timestamp for benchmark tracking should not be flagged"
);
}
#[test]
fn test_DET002_allows_benchmark_result_comment() {
let script = r#"#!/bin/bash
# Generate benchmark result file
RESULT_FILE="results/baseline_$(date +%s).md"
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Timestamp for benchmark/logging should be allowed with marker comment"
);
}
#[test]
fn test_GH230_det002_comparison_sink_not_flagged() {
let script = r#"#!/bin/bash
# Intentional: timestamp for result tracking
if [ $(date +%s) -gt 1000 ]; then
echo "error"
fi
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Timestamp in a comparison is not a reproducible-output defect (GH-230)"
);
}
#[test]
fn test_DET002_allows_metrics_recording_marker() {
let script = r#"#!/bin/bash
# Metrics recording script - timestamps are THE PURPOSE
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
METRIC_FILE="metrics_$TIMESTAMP.json"
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Metrics recording script should not flag DET002 (Issue #58)"
);
}
#[test]
fn test_DET002_allows_record_metric_marker() {
let script = r#"#!/bin/bash
# Record metric to pmat database
TIMESTAMP=$(date +%s)
echo "$TIMESTAMP,$VALUE" >> metrics.csv
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Record-metric script should not flag DET002 (Issue #58)"
);
}
#[test]
fn test_DET002_allows_telemetry_marker() {
let script = r#"#!/bin/bash
# Telemetry collection for observability
TIMESTAMP=$(date +%s)
send_metric "$TIMESTAMP"
"#;
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"Telemetry script should not flag DET002 (Issue #58)"
);
}
}