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
//! What:
//! The built-in `final-newline` policy:
//! a non-empty UTF-8 text file without NUL
//! bytes must end with exactly one LF byte.
//! Why:
//! The rule,
//! its five preserved path families and its finding are the installed
//! wrapper's (`final-newline-policy.ts`,
//! `final-newline-normalize.ts`),
//! so the native
//! wrapper reports the same files with the same words.
//! The normalized bytes are
//! also the full-content correction a fixing lifecycle applies.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! // const replacement = normalizeFinalNewline(bytes); if (replacement.kind === 'changed') findings.push(...);
//! ```
/// Import the candidate's mode.
use super::candidate_object::CandidateMode;
/// Import the candidate's change kinds.
use super::candidate_record::CandidateChange;
/// Import the candidate and version types.
use super::candidate_version::{Candidate, CandidateVersion};
/// Import the event form of a pathname.
use super::event_path::EventPath;
/// Import the lifecycle's candidates and the correction a fix applies.
use super::policy_content::{ContentState, Correction, LifecycleContent};
/// Import the finding and outcome types of a check.
use super::policy_engine::{PolicyFinding, PolicyOutcome};
/// Import the lifecycle points;
/// only a direct fix corrects.
use super::policy_trigger::Trigger;
/// Import the facts interface that prepares candidates.
use super::repository_facts::RepositoryFacts;
/// `Rc<T>` is a shared,
/// read-only handle.
use std::rc::Rc;
/// The policy-local code of a final-newline finding.
pub const FINAL_NEWLINE_CODE: &str = "noncanonical-final-newline";
/// The message of a final-newline finding,
/// as the installed wrapper words it.
pub const FINAL_NEWLINE_MESSAGE: &str = "Non-empty text file must end with exactly one LF byte.";
/// What:
/// Path prefixes whose files keep their exact bytes.
/// `&[&[u8]]` is a borrowed list
/// of byte strings baked into the program.
/// Why:
/// Fuzz seeds and a fixture source are inputs whose trailing bytes are the test.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const PRESERVED_PREFIXES = ['package/cli/forbidden-strings.fuzz/seed/', ...];
/// ```
const PRESERVED_PREFIXES: &[&[u8]] = &[
b"package/cli/forbidden-strings.fuzz/seed/",
b"package/rust-module/forbidden-regex.fuzz/seed/",
b"package/test-fixture/toml-edit/src/",
];
/// What:
/// Whether one byte is `/`,
/// the separator of Git pathnames.
/// Why:
/// A named predicate for `split`,
/// because the repository bans anonymous functions.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const isSlash = (byte: number) => byte === 0x2f;
/// ```
fn is_slash(byte: &u8) -> bool {
return *byte == b'/';
}
/// What:
/// Whether the policy preserves the bytes of the file at `path`.
/// Why:
/// The three prefixes above,
/// any file below a `dist/final/node/` directory,
/// and any
/// file below a `bundle/node/` directory hold generated or fixture bytes.
/// A path
/// that merely ends in such a directory name holds no file below it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function isFinalNewlineExcluded(path: string): boolean;
/// ```
pub fn is_final_newline_excluded(path: &[u8]) -> bool {
for prefix in PRESERVED_PREFIXES {
if path.starts_with(prefix) {
return true;
}
}
let segments: Vec<&[u8]> = path.split(is_slash).collect();
// `.windows(4)` visits each run of four neighbouring segments: three directory names
// and at least one more segment below them.
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;
}
/// What:
/// Whether one byte is not LF.
/// Why:
/// A named predicate for `rposition`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const isContentByte = (byte: number) => byte !== 0x0a;
/// ```
fn is_content_byte(byte: &u8) -> bool {
return *byte != b'\n';
}
/// What:
/// The canonical bytes of a file,
/// or nothing when they are already canonical or
/// not text.
/// `Option<Vec<u8>>` is "replacement bytes or nothing".
/// Why:
/// Empty files,
/// files with a NUL byte and files that are not UTF-8 are left alone;
/// every other file ends with exactly one LF:
/// missing ones are added and extra
/// ones removed,
/// interior bytes untouched.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function normalizeFinalNewline(bytes: Uint8Array): Uint8Array | undefined;
/// ```
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;
}
// What: `.iter().rposition(..)` is the index of the last byte that is not LF, if any.
// Why: The content ends just after it; a file of LF bytes alone has no content.
//
// In TS you'd write (pseudocode):
// ```ts
// const contentEnd = bytes.findLastIndex(isContentByte) + 1;
// ```
let content_end: usize = match bytes.iter().rposition(is_content_byte) {
Some(last) => last + 1,
None => 0,
};
if content_end + 1 == bytes.len() {
// Exactly one LF follows the content: the bytes are canonical.
return None;
}
// `.to_vec()` copies the content; `.push` appends the one LF.
let mut normalized: Vec<u8> = bytes[..content_end].to_vec();
normalized.push(b'\n');
return Some(normalized);
}
/// What:
/// Whether the policy reads a candidate at all.
/// Why:
/// Deletions have no content;
/// symbolic links and submodules are not files of text;
/// preserved paths keep their bytes.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const checked = candidate.change !== 'deleted' && (candidate.mode === 'regular' || candidate.mode === 'executable') && !isFinalNewlineExcluded(candidate.path);
/// ```
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());
}
/// What:
/// The finding for one candidate.
/// `EventPath::from_git_bytes` renders the pathname
/// as text,
/// replacing bytes that are not UTF-8,
/// and keeps such bytes exactly.
/// Why:
/// The finding names the file the person must change,
/// and says whether this
/// lifecycle proposed the correction.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const finding = { code: 'noncanonical-final-newline', message, path: candidate.path, patch };
/// ```
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,
};
}
/// What:
/// Check every candidate of the lifecycle.
/// `<F: RepositoryFacts>` accepts any facts
/// provider;
/// `&mut ContentState` lends the shared candidates for reading and takes
/// the corrections.
/// Why:
/// One finding per file whose bytes are not canonical,
/// in candidate order;
/// no
/// candidates means no findings,
/// and unreadable content means the policy failed.
/// Only a direct fix applies corrections (the installed wrapper's
/// `canApplyPatches`),
/// so only there does each finding carry one.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function checkFinalNewline(content, lifecycle, facts, trigger): Promise<PolicyOutcome>;
/// ```
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,
// `Vec::new()` is the empty list: a lifecycle without candidates has nothing to report.
Ok(None) => return PolicyOutcome::Findings(Vec::new()),
Err(outcome) => return outcome,
};
let mut findings: Vec<PolicyFinding> = 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;
};
let corrects: bool = trigger == Trigger::DirectFix || can_apply_patches;
if corrects {
content.propose(Correction {
path: candidate.path.clone(),
mode: candidate.mode,
before: bytes,
// `Rc::from(vec)` moves the bytes into a shared handle.
after: Rc::from(normalized),
});
}
findings.push(finding(candidate, corrects));
}
return PolicyOutcome::Findings(findings);
}
/// Normalization,
/// exclusion and check controls stay out of the release executable.
#[cfg(test)]
#[path = "policy_final_newline_tests.rs"]
mod tests;