codelore-lib 0.28.0

CodeLore — Behavioral Code Analyzer library
Documentation
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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! Guard: repository strings reaching an HTML sink in the SPA are escaped.
//!
//! The dashboard embeds its data as JSON and every widget builds markup by
//! string concatenation, so any repository-derived string — a path, a module
//! name, an author — is one missing `escapeHtml` away from executing in the
//! viewer's browser. Path names may legally contain `<` and `>` on Linux and
//! macOS, git tracks them verbatim, and the emitter's only JSON defence is
//! `"</"` → `"<\\/"`, which prevents `</script>` breakout and nothing else:
//! after `JSON.parse` the string carries its metacharacters intact, and the
//! next `innerHTML` concatenation is a fresh injection point that the
//! transport-level fix has no jurisdiction over. Escaping has to happen at
//! the sink, which is why the house convention is `escapeHtml` there.
//!
//! The class recurs. One widget built an `onclick` by concatenating row data
//! into an attribute; three cycles later a different widget concatenated
//! module paths into two chart tooltips — same defect, new file, because
//! nothing enforced the convention. This guard is the enforcement.
//!
//! What it checks: in any statement that also builds markup, an accessor
//! naming a repository-derived string must sit inside `escapeHtml(...)`.
//! Numeric fields are not listed — they cannot carry markup.
//!
//! The accessor list is **curated, not derived**. The analyses carry
//! roughly seventy distinct `String` field names between them and this list
//! names a fraction: most of the rest are computed rather than
//! repository-derived — a band, a verdict, a trend, a date, a revision
//! hash — and cannot carry a metacharacter no matter what the analysed
//! repository contains. Curation is what keeps the list short enough to
//! read, and it is also the standing liability: a genuinely
//! repository-derived field is covered only if someone adds it. Two are
//! knowingly absent, because a single-letter accessor cannot work under
//! prefix matching — `.a` would swallow `.author`, `.added` and
//! `.arch_band` — so the function-coupling endpoints are unguarded should
//! they ever be rendered rather than used as lookup keys.
//!
//! What it does not check: markup assembled across statement boundaries —
//! including a repository string parked in a local by one statement and
//! rendered by the next — or a field added to the payload without being
//! added below. It is a convention guard, not a taint tracker; the
//! statement of its limits is part of the guard.

use std::path::Path;

/// Every widget source `output::spa` concatenates, read from the directory
/// rather than listed here. A list would have to be edited twice — once
/// beside the emitter, once beside the guard — and the edit that gets
/// forgotten is the second one, leaving a new widget unscanned in exactly
/// the case this guard exists for: a file that never adopted the convention.
fn widget_sources() -> Vec<(String, String)> {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/output/spa/js");
    let mut out = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("js") {
                continue;
            }
            let name = path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or_default()
                .to_owned();
            let src = std::fs::read_to_string(&path).expect("read widget source");
            out.push((name, src));
        }
    }
    out.sort();
    assert!(
        !out.is_empty(),
        "scanned zero widget sources — source-path resolution is broken"
    );
    out
}

/// Accessors whose value is a repository-derived string: the string fields
/// of the SPA JSON payload, the two chart-library carriers (`p.name`,
/// `order[...]`) that receive them, and a few fields carried by analyses
/// the dashboard does not embed today — cheap cover for the day one is.
///
/// Matching is by prefix, so `.entity` covers `.entity_a` and `.entity_b`:
/// a suffixed variant lands at the same byte offset, is judged from that
/// same offset, and so can only report the one defect twice. Suffix
/// compounds therefore need no entry of their own.
///
/// The `_`-prefixed entries cover the opposite shape, a qualifier-first
/// compound: `main_author` is a payload field rendered today, and `.author`
/// does not occur in it because the character before `author` is `_`.
const RAW_STRING_ACCESSORS: &[&str] = &[
    ".path",
    ".entity",
    ".author",
    ".canonical_author",
    ".source",
    ".target",
    ".module",
    ".tag",
    ".name",
    ".function",
    "order[",
    "_author",
    "_path",
    "_name",
];

/// A superset of the tag names the widgets emit today — eight are not used
/// anywhere yet, and are kept so markup written tomorrow is watched on the
/// day it lands rather than the day someone remembers this list. Checked
/// against the token following a `<` that opens a tag *inside a string
/// literal* — see `builds_markup`.
const HTML_TAGS: &[&str] = &[
    "div", "span", "br", "strong", "p", "td", "tr", "th", "dt", "dd", "dl", "li", "ul", "ol", "h1",
    "h2", "h3", "h4", "h5", "code", "b", "em", "i", "a", "img", "option", "small", "label",
    "button", "svg", "table", "tbody", "thead",
];

/// Markup evidence that is not an opening tag: entities, an attribute
/// assignment, and the sink itself. The entities are written without their
/// terminating `;` on purpose — statements are split on `;`, which would
/// otherwise cut every entity in half and make these unmatchable.
const HTML_MARKERS: &[&str] = &[
    "innerHTML",
    "&rarr",
    "&harr",
    "&middot",
    "&nbsp",
    "title=\"",
];

/// Whether a statement builds markup rather than plain text.
///
/// A `<` only counts when it opens a known tag inside a string literal.
/// Both halves of that rule pay for themselves: without the literal
/// anchor, `evt.target` in a `for` loop's `j < n` reads as markup and every
/// accessor in the enclosing handler is flagged; without the tag-name
/// check, the literal `'<anonymous>'` — the placeholder for an unnamed
/// function in the X-ray widget — reads as an opening tag.
fn builds_markup(stmt: &str) -> bool {
    HTML_MARKERS.iter().any(|m| stmt.contains(m))
        || literal_tags(stmt).any(|(_, tag)| HTML_TAGS.contains(&tag))
}

/// Every tag name opened inside a string literal in `s`, with its offset.
///
/// The literal anchor and the name extraction live here because two callers
/// need the same answer: `builds_markup` asks whether any of them is a tag
/// it knows, and `every_tag_the_widgets_open_is_covered` asks whether any of
/// them is one it does not.
fn literal_tags(s: &str) -> impl Iterator<Item = (usize, &str)> {
    s.match_indices('<').filter_map(move |(i, _)| {
        if !s[..i].trim_end_matches(' ').ends_with(['\'', '"']) {
            return None;
        }
        let rest = &s[i + 1..];
        let rest = rest.strip_prefix('/').unwrap_or(rest);
        let len = rest
            .find(|c: char| !c.is_ascii_alphanumeric())
            .unwrap_or(rest.len());
        (len > 0).then(|| (i, &rest[..len]))
    })
}

/// Statement-ish slices. Markup here is built by concatenation terminated
/// by `;`, so splitting there keeps a multi-line `return` whole while
/// separating unrelated code.
///
/// HTML entities end in `;` too. Splitting on those would cut a statement
/// at `&rarr;` — severing it from the very marker that identifies it as
/// markup, and hiding every accessor after the entity. So a `;` that closes
/// an entity is not a statement boundary.
/// Each slice is paired with its byte offset in `src`, so a violation can
/// name the line in the file rather than the line within the statement —
/// the latter reads like a file line and points hundreds of lines away.
fn statements(src: &str) -> Vec<(usize, &str)> {
    let mut out = Vec::new();
    let mut start = 0;
    for (i, _) in src.match_indices(';') {
        let head = src[..i].trim_end_matches(|c: char| c.is_ascii_alphanumeric());
        // `head.len() < i` means at least one name character was trimmed;
        // without it a bare `&;` would read as an entity and never split.
        let closes_entity = head.len() < i && head.ends_with('&');
        if !closes_entity {
            out.push((start, &src[start..i]));
            start = i + 1;
        }
    }
    out.push((start, &src[start..]));
    out
}

/// What precedes the member expression containing `pos`, with the
/// expression's own identifier chain walked off. Trimming the chain rather
/// than indexing back over it keeps the slice on a character boundary for
/// free — the widgets carry `—` and `·`, and hand-rolled index arithmetic
/// here once split one mid-character.
fn preceding(stmt: &str, pos: usize) -> &str {
    stmt[..pos]
        .trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '.')
        .trim_end()
}

/// Whether the expression at `pos` sits inside an `escapeHtml(...)` call.
///
/// Found by walking back to the innermost unclosed `(`, not by testing the
/// text immediately before the accessor. The difference is real: in
/// `escapeHtml(a.axisValueLabel || b.name)` a single call escapes *both*
/// operands, but only the first is adjacent to it — the immediate-prefix
/// test read `b.name` as a bare sink and reported a file that was correct.
fn is_escaped(stmt: &str, pos: usize) -> bool {
    let mut depth = 0usize;
    for (i, c) in stmt[..pos].char_indices().rev() {
        match c {
            ')' => depth += 1,
            '(' if depth == 0 => {
                let head = stmt[..i].trim_end();
                // `.` is not trimmed here: the callee's own name is wanted,
                // so a qualified `a.escapeHtml(` must not read as bare
                // `escapeHtml`.
                let name = head
                    .trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$')
                    .len();
                return &head[name..] == "escapeHtml";
            }
            '(' => depth -= 1,
            _ => {}
        }
    }
    false
}

/// Whether the expression at `pos` is a subscript — `moduleRole[p.name]`
/// looks up a role by path, so the path is a key, not rendered output.
/// Escaping it would break the lookup rather than secure it.
fn is_lookup_key(stmt: &str, pos: usize) -> bool {
    preceding(stmt, pos).ends_with('[')
}

/// Unescaped raw-string accessors inside markup-building statements.
fn unescaped_sinks(src: &str) -> Vec<String> {
    let mut out = Vec::new();
    for (offset, stmt) in statements(src) {
        if !builds_markup(stmt) {
            continue;
        }
        for accessor in RAW_STRING_ACCESSORS {
            for (at, _) in stmt.match_indices(accessor) {
                if !is_escaped(stmt, at) && !is_lookup_key(stmt, at) {
                    let line = src[..offset + at].matches('\n').count() + 1;
                    out.push(format!("{line}: {accessor}"));
                }
            }
        }
    }
    out
}

#[test]
fn no_widget_concatenates_repository_strings_into_markup_unescaped() {
    let mut violations = Vec::new();
    for (name, src) in widget_sources() {
        for hit in unescaped_sinks(&src) {
            violations.push(format!("  {name}:{hit}"));
        }
    }

    assert!(
        violations.is_empty(),
        "{} SPA sink(s) interpolate a repository-derived string into markup \
         without `escapeHtml`:\n{}\n\n\
         Repository paths may contain `<` and `>`; they reach the browser \
         verbatim because the emitter escapes only `</` in the JSON payload. \
         An unescaped concatenation into `innerHTML` — including the return \
         value of an ECharts function formatter, which is inserted as markup \
         rather than filtered like a `{{b}}` template — executes whatever the \
         analysed repository put in that path.\n\n\
         Wrap the value in `escapeHtml(...)`, the helper every other widget \
         already uses.",
        violations.len(),
        violations.join("\n"),
    );
}

/// Literal-adjacent `<token>` strings in the widgets that are not markup:
/// the X-ray widget's placeholder for an unnamed function, and the import
/// graph's label for the tree root. Both are proof that a bare "any token
/// after `<`" rule would misread text as markup.
const NON_TAG_PLACEHOLDERS: &[&str] = &["anonymous", "root"];

/// Repository strings parked in a local by one statement and rendered by the
/// next — the shape `unescaped_sinks` provably cannot reach, because it reads
/// one statement at a time and the local carries no accessor to match.
///
/// Naming the two sites is an instance list, which is the thing this file
/// spends its length avoiding. It earns the exception by covering a class the
/// general rule cannot express rather than one nobody generalised: every
/// accessor rule that catches a bare local also flags a comment, a `Path`
/// column header, a truthiness test and the numeric local `activeAuthors` —
/// four false positives on a clean tree, measured, not assumed.
///
/// `rowAuthor` is why this is worth an exception at all. It renders into
/// `data-primary-author="…"`, an attribute, which is exactly where the
/// quote-breakout vector lands — and a git author name takes a quote
/// verbatim. Its apparent coverage was incidental: the guard caught it only
/// because a `.path` sat in the same statement, and with that path left
/// escaped the sink is invisible.
const LOCAL_CARRIED_SINKS: &[(&str, &str)] = &[
    ("12_drawer.js", "partnerAuthor"),
    ("20_hotspots.js", "rowAuthor"),
];

#[test]
fn repository_strings_parked_in_locals_are_escaped_where_they_render() {
    let sources = widget_sources();
    for (file, local) in LOCAL_CARRIED_SINKS {
        let Some((_, src)) = sources.iter().find(|(name, _)| name == file) else {
            panic!("{file} is no longer a widget source");
        };

        let rendered: Vec<_> = src
            .lines()
            .enumerate()
            .filter(|(_, line)| line.contains(local) && builds_markup(line))
            .collect();

        assert!(
            !rendered.is_empty(),
            "{file} no longer renders `{local}` into markup. Either the sink \
             moved — re-point this entry at it — or it is gone and the entry \
             should be. An instance list that describes code which has moved \
             is worse than no list, because it reads as coverage."
        );
        for (idx, line) in rendered {
            assert!(
                line.contains(&format!("escapeHtml({local}")),
                "{file}:{}: `{local}` reaches markup unescaped.\n  {}\n\n\
                 This sink is invisible to the statement scan — the value is \
                 a local, so there is no accessor to match — which is why it \
                 is pinned by name here.",
                idx + 1,
                line.trim(),
            );
        }
    }
}

#[test]
fn every_tag_the_widgets_open_is_covered() {
    let mut missing = Vec::new();
    for (name, src) in widget_sources() {
        for (at, tag) in literal_tags(&src) {
            if HTML_TAGS.contains(&tag) || NON_TAG_PLACEHOLDERS.contains(&tag) {
                continue;
            }
            let line = src[..at].matches('\n').count() + 1;
            missing.push(format!("  {name}:{line}: <{tag}"));
        }
    }
    missing.sort();
    missing.dedup();

    assert!(
        missing.is_empty(),
        "{} tag(s) opened in widget markup are absent from `HTML_TAGS`:\n{}\n\n\
         `builds_markup` examines a statement only when it recognises the \
         markup, so a tag missing from that list silently takes every \
         accessor in the statement out of coverage — no failure, no output, \
         just a blind spot. That is the defect this guard was widened to \
         close, and the list is the one place it can reopen.\n\n\
         Add the tag, or if the token is not markup, add it to \
         `NON_TAG_PLACEHOLDERS` with the reason.",
        missing.len(),
        missing.join("\n"),
    );
}

#[test]
fn the_guard_catches_the_shape_it_exists_for() {
    // The real check passes when the tree is clean, which is also what a
    // broken matcher looks like. Pin it against the defect it was written
    // for — the architecture tooltips, in their pre-fix form — and against
    // the fixed form, so neither a vacuous pass nor a false positive can
    // hide. An earlier draft of this matcher required a `+` before the
    // accessor and allowed only one member segment; it reported zero
    // violations on the vulnerable code below.
    let vulnerable =
        "return 'Imports: ' + p.data.source + ' &rarr; ' + p.data.target + ' (' + n + ')';";
    assert_eq!(
        unescaped_sinks(vulnerable).len(),
        2,
        "must flag both unescaped edge endpoints"
    );

    let vulnerable_leading = "return p.name + '<br/>role: ' + role;";
    assert_eq!(
        unescaped_sinks(vulnerable_leading).len(),
        1,
        "must flag an accessor that opens the expression, with no `+` before it"
    );

    let vulnerable_index = "return order[r] + ' &rarr; ' + order[c] + '<br/>' + v;";
    assert_eq!(
        unescaped_sinks(vulnerable_index).len(),
        2,
        "must flag indexed axis labels"
    );

    let fixed =
        "return 'Imports: ' + escapeHtml(p.data.source) + ' &rarr; ' + escapeHtml(p.data.target);";
    assert_eq!(
        unescaped_sinks(fixed).len(),
        0,
        "must accept the escaped form"
    );

    // Markup-free statements are out of scope even when they carry paths,
    // or every data-plumbing line in the file would be a violation.
    let not_markup = "const rm = modulePath(rr.path, chosenDepth);";
    assert_eq!(
        unescaped_sinks(not_markup).len(),
        0,
        "a statement that builds no markup is not a sink"
    );

    // A path used as a subscript is a key, not rendered output. Escaping it
    // would change what is looked up rather than secure anything.
    let lookup = "return escapeHtml(p.name) + '<br/>role: ' + (moduleRole[p.name] || 'periphery');";
    assert_eq!(
        unescaped_sinks(lookup).len(),
        0,
        "an accessor inside a subscript is a lookup key, not a sink"
    );
}

#[test]
fn the_guard_reaches_table_markup_and_qualifier_first_fields() {
    // Author names are the sharper vector of this class: a path needs `<`,
    // which only some filesystems allow, but `git commit --author` accepts
    // a quote verbatim, and author identity is rendered into attributes.
    // Both properties of the sink that carries it — markup built from
    // table/list tags, and a field named `main_author` — were once
    // invisible here, so each gets a case.
    let compound = "html += '<td>' + (r.main_author || '') + '</td>';";
    assert_eq!(
        unescaped_sinks(compound).len(),
        1,
        "a qualifier-first compound field is a sink; `.author` does not occur in `main_author`"
    );
    assert_eq!(
        unescaped_sinks("html += '<td>' + escapeHtml(r.main_author || '') + '</td>';").len(),
        0,
        "must accept the escaped compound field"
    );

    // A function name is parsed out of the analysed repository's source, so
    // it carries whatever that source put in an identifier position — the
    // same provenance as a path, and rendered in the same drawer.
    let parsed_identifier = "html += '<li><code>' + (f.function || '') + '</code></li>';";
    assert_eq!(
        unescaped_sinks(parsed_identifier).len(),
        1,
        "a function name parsed from the analysed source is repository-derived"
    );

    let suffix = "return '<div>' + p.entity_a + '</div>';";
    assert_eq!(
        unescaped_sinks(suffix).len(),
        1,
        "a suffix compound is covered by its base accessor, with no entry of its own"
    );

    // `escapeHtml(a || b)` escapes both operands with one call. Reading
    // only the text adjacent to the accessor reported the second as bare.
    let disjunction = "var html = '<b>' + escapeHtml(p.axisValueLabel || p.name) + '</b>';";
    assert_eq!(
        unescaped_sinks(disjunction).len(),
        0,
        "one escapeHtml call covers every operand inside its parentheses"
    );

    // A `<` that is a comparison, not a tag.
    let comparison = "if (a.name < b.name) { rank = 1; }";
    assert_eq!(
        unescaped_sinks(comparison).len(),
        0,
        "a less-than outside a string literal does not make a statement markup"
    );

    // A string literal shaped like a tag but naming no element.
    let placeholder = "node.children.push({ label: r.function || '<anonymous>', value: r.path });";
    assert_eq!(
        unescaped_sinks(placeholder).len(),
        0,
        "`<anonymous>` is a placeholder in the X-ray widget, not an opening tag"
    );
}