use crate::candidate_object::CandidateMode;
use crate::candidate_record::CandidateChange;
use crate::candidate_version::{Candidate, CandidateVersion};
use crate::event_path::EventPath;
use crate::policy_content::{ContentState, Correction, LifecycleContent};
use crate::policy_engine::{PolicyFinding, PolicyOutcome};
use crate::policy_trigger::Trigger;
use crate::repository_facts::RepositoryFacts;
use std::collections::HashSet;
use std::rc::Rc;
pub const FINAL_NEWLINE_CODE: &str = "noncanonical-final-newline";
pub const FINAL_NEWLINE_MESSAGE: &str = "Non-empty text file must end with exactly one LF byte.";
fn is_slash(byte: &u8) -> bool {
return *byte == b'/';
}
pub fn is_final_newline_excluded(path: &[u8]) -> bool {
let segments: Vec<&[u8]> = path.split(is_slash).collect();
for run in segments.windows(4) {
if run[0] == b"dist" && run[1] == b"final" && run[2] == b"node" {
return true;
}
}
for run in segments.windows(3) {
if run[0] == b"bundle" && run[1] == b"node" {
return true;
}
}
return false;
}
fn is_content_byte(byte: &u8) -> bool {
return *byte != b'\n';
}
pub fn normalized_final_newline(bytes: &[u8]) -> Option<Vec<u8>> {
if bytes.is_empty() || bytes.contains(&0) || std::str::from_utf8(bytes).is_err() {
return None;
}
let content_end: usize = match bytes.iter().rposition(is_content_byte) {
Some(last) => last + 1,
None => 0,
};
if content_end + 1 == bytes.len() {
return None;
}
let mut normalized: Vec<u8> = bytes[..content_end].to_vec();
normalized.push(b'\n');
return Some(normalized);
}
fn is_checked(candidate: &Candidate) -> bool {
if candidate.change == CandidateChange::Deleted {
return false;
}
if candidate.mode != CandidateMode::Regular && candidate.mode != CandidateMode::Executable {
return false;
}
return !is_final_newline_excluded(candidate.path.as_slice());
}
fn finding(candidate: &Candidate, fix_available: bool) -> PolicyFinding {
return PolicyFinding {
code: FINAL_NEWLINE_CODE,
message: String::from(FINAL_NEWLINE_MESSAGE),
path: Some(EventPath::from_git_bytes(candidate.path.as_slice())),
location: None,
fix_available,
};
}
pub fn check_final_newline<F: RepositoryFacts>(
content: &mut ContentState,
lifecycle: &LifecycleContent,
facts: &mut F,
trigger: Trigger,
can_apply_patches: bool,
) -> PolicyOutcome {
let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
Ok(Some(found)) => found,
Ok(None) => return PolicyOutcome::Findings(Vec::new()),
Err(outcome) => return outcome,
};
let mut pending: Vec<(&Candidate, Rc<[u8]>, Vec<u8>)> = Vec::new();
for candidate in version.candidates() {
if !is_checked(candidate) {
continue;
}
let bytes: Rc<[u8]> = match content.bytes(candidate) {
Ok(read) => read,
Err(outcome) => return outcome,
};
let Some(normalized) = normalized_final_newline(&bytes) else {
continue;
};
pending.push((candidate, bytes, normalized));
}
let paths: Vec<&[u8]> = pending
.iter()
.map(|(candidate, _, _)| { return candidate.path.as_slice(); })
.collect();
let unset: HashSet<Vec<u8>> = match content.text_unset(paths.as_slice()) {
Ok(found) => found,
Err(outcome) => return outcome,
};
let mut findings: Vec<PolicyFinding> = Vec::new();
for (candidate, bytes, normalized) in pending {
if unset.contains(&candidate.path) {
continue;
}
let corrects: bool = trigger == Trigger::DirectFix || can_apply_patches;
if corrects {
content.propose(Correction {
path: candidate.path.clone(),
mode: candidate.mode,
before: bytes,
after: Rc::from(normalized),
});
}
findings.push(finding(candidate, corrects));
}
return PolicyOutcome::Findings(findings);
}
#[cfg(test)]
#[path = "policy_final_newline_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "policy_final_newline_attribute_tests.rs"]
mod attribute_tests;