Skip to content

Commit 432aa77

Browse files
olehermanseclaude
andcommitted
cfengine lint: Added checking of CSV files
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Ole Herman Schumacher Elgesem <ole@northern.tech>
1 parent e8e16e6 commit 432aa77

3 files changed

Lines changed: 148 additions & 1 deletion

File tree

src/cfengine_cli/lint.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- *.cf (policy files)
66
- cfbs.json (CFEngine Build project files)
77
- *.json (basic JSON syntax checking)
8+
- *.csv (basic CSV syntax + RFC 4180 CRLF record terminator check)
89
910
This is performed in 3 steps:
1011
1. Parsing - Read the .cf files and convert them into syntax trees
@@ -38,9 +39,10 @@
3839
from cfbs.validate import validate_config
3940
from cfbs.cfbs_config import CFBSConfig
4041
from cfbs.utils import find
42+
from cfengine_cli.lint_csv import check_csv_file
4143
from cfengine_cli.utils import UserError
4244

43-
LINT_EXTENSIONS = (".cf", ".cf.sub", ".json")
45+
LINT_EXTENSIONS = (".cf", ".cf.sub", ".json", ".csv")
4446
DEFAULT_NAMESPACE = "default"
4547
VARS_TYPES = {
4648
"data",
@@ -1191,6 +1193,9 @@ def _lint_main(
11911193
if filename.endswith(".json"):
11921194
errors += _lint_json_selector(filename)
11931195
continue
1196+
if filename.endswith(".csv"):
1197+
errors += _lint_csv(filename)
1198+
continue
11941199
assert filename.endswith((".cf", ".cf.sub"))
11951200
policy_file = PolicyFile(filename, snippet)
11961201
r = _check_syntax(policy_file, state)
@@ -1328,6 +1333,19 @@ def _lint_json_selector(file: str) -> int:
13281333
return _lint_json_plain(file)
13291334

13301335

1336+
def _lint_csv(filename: str) -> int:
1337+
"""Lint a CSV file: check that csv parses, and that record terminators
1338+
are CRLF (per RFC 4180)."""
1339+
assert os.path.isfile(filename)
1340+
problem = check_csv_file(filename)
1341+
r = 0
1342+
if problem is not None:
1343+
print(f"{filename}: {problem}")
1344+
r = 1
1345+
print(_pass_fail_filename(filename, r))
1346+
return r
1347+
1348+
13311349
# ---------------------------------------------------------------------------
13321350
# Syntax error detection (used by both linter and formatter)
13331351
# ---------------------------------------------------------------------------

src/cfengine_cli/lint_csv.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""CSV file validation per RFC 4180.
2+
3+
The grammar in RFC 4180 mandates CRLF between records. Bare \\r or \\n
4+
outside of quoted fields is not valid. Inside quoted fields, \\r and \\n
5+
are allowed as field content.
6+
"""
7+
8+
import csv
9+
10+
11+
def check_csv_record_terminators(raw: str) -> str | None:
12+
"""Check that all record terminators in a CSV string are CRLF.
13+
14+
Returns None if all record terminators are CRLF, otherwise a short
15+
description of the problem.
16+
"""
17+
in_quotes = False
18+
_prev = None
19+
prev = None
20+
for current in raw:
21+
prev = _prev
22+
_prev = current
23+
if current == '"':
24+
in_quotes = not in_quotes
25+
continue
26+
if in_quotes:
27+
continue
28+
if current == "\n" and prev != "\r":
29+
return "bare LF outside quoted field"
30+
if prev == "\r" and current != "\n":
31+
return "bare CR outside quoted field"
32+
if _prev == "\r" and not in_quotes:
33+
return "bare CR outside quoted field"
34+
return None
35+
36+
37+
def check_csv_file(filename: str) -> str | None:
38+
"""Check a CSV file: parses, has at least one non-empty record, and uses
39+
CRLF record terminators.
40+
41+
Returns None if valid, otherwise a short description of the problem.
42+
"""
43+
try:
44+
with open(filename, newline="") as f:
45+
raw = f.read()
46+
with open(filename, newline="") as f:
47+
rows = list(csv.reader(f, strict=True))
48+
except (OSError, csv.Error) as e:
49+
return str(e)
50+
if not any(rows):
51+
return "no records"
52+
return check_csv_record_terminators(raw)

tests/unit/test_lint_csv.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
import tempfile
3+
4+
import pytest
5+
6+
from cfengine_cli.lint_csv import check_csv_file, check_csv_record_terminators
7+
8+
9+
def _write_temp_csv(content: bytes) -> str:
10+
fd, path = tempfile.mkstemp(suffix=".csv")
11+
with os.fdopen(fd, "wb") as f:
12+
f.write(content)
13+
return path
14+
15+
16+
VALID = [
17+
("crlf_terminated", b"a,b,c\r\n1,2,3\r\n"),
18+
("crlf_no_trailing_newline", b"a,b,c\r\n1,2,3"),
19+
("single_record_no_newline", b"a,b,c"),
20+
("row_of_empty_fields", b",,\r\n"),
21+
("lf_inside_quoted_field", b'a,"line1\nline2",c\r\n'),
22+
("cr_inside_quoted_field", b'a,"line1\rline2",c\r\n'),
23+
("crlf_inside_quoted_field", b'a,"line1\r\nline2",c\r\n'),
24+
("escaped_quote_inside_field", b'a,"he said ""hi""",c\r\n'),
25+
]
26+
27+
INVALID = [
28+
("empty_file", b""),
29+
("only_one_empty_line", b"\r\n"),
30+
("only_empty_lines", b"\r\n\r\n\r\n"),
31+
("lf_only_line_endings", b"a,b,c\n1,2,3\n"),
32+
("cr_only_line_endings", b"a,b,c\r1,2,3\r"),
33+
("mixed_crlf_then_bare_lf", b"a,b,c\r\n1,2,3\nx,y,z\r\n"),
34+
("bare_cr_mid_record", b"a,b\rc,d\r\n"),
35+
("trailing_bare_cr", b"a,b,c\r"),
36+
("trailing_bare_lf", b"a,b,c\n"),
37+
]
38+
39+
40+
@pytest.mark.parametrize("content", [c for _, c in VALID], ids=[n for n, _ in VALID])
41+
def test_check_csv_file_accepts_valid(content):
42+
path = _write_temp_csv(content)
43+
try:
44+
assert check_csv_file(path) is None
45+
finally:
46+
os.unlink(path)
47+
48+
49+
@pytest.mark.parametrize(
50+
"content", [c for _, c in INVALID], ids=[n for n, _ in INVALID]
51+
)
52+
def test_check_csv_file_rejects_invalid(content):
53+
path = _write_temp_csv(content)
54+
try:
55+
assert check_csv_file(path) is not None
56+
finally:
57+
os.unlink(path)
58+
59+
60+
def test_check_csv_record_terminators_accepts_crlf():
61+
assert check_csv_record_terminators("a,b\r\nc,d\r\n") is None
62+
63+
64+
def test_check_csv_record_terminators_allows_newlines_inside_quotes():
65+
assert check_csv_record_terminators('"a\nb\rc\r\nd"\r\n') is None
66+
67+
68+
def test_check_csv_record_terminators_rejects_bare_lf():
69+
assert check_csv_record_terminators("a,b\nc,d\n") == "bare LF outside quoted field"
70+
71+
72+
def test_check_csv_record_terminators_rejects_bare_cr():
73+
assert check_csv_record_terminators("a,b\rc,d") == "bare CR outside quoted field"
74+
75+
76+
def test_check_csv_record_terminators_rejects_trailing_bare_cr():
77+
assert check_csv_record_terminators("a,b\r") == "bare CR outside quoted field"

0 commit comments

Comments
 (0)