Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import 'package:gitlab_models/gitlab_models.dart';

/// Literal metadata and deeply immutable, complete original diff hunks.
typedef LiteralOriginalDiff = ({CommitDiffFile file, List<DiffHunk> hunks});

final _header = RegExp(
r'^@@ -(0|[1-9][0-9]*)(?:,(0|[1-9][0-9]*))? '
r'\+(0|[1-9][0-9]*)(?:,(0|[1-9][0-9]*))? @@(?: [^\r\n]*)?$',
);
// Conservative coordinate bound for review positions, across supported runtimes.
const _maxCoordinate = 2147483647;

bool _supportedPath(String path) =>
path.isNotEmpty &&
!path.startsWith('/') &&
!path.contains(RegExp(r'[\x00\r\n]')) &&
path
.split('/')
.every((part) => part.isNotEmpty && part != '.' && part != '..');

/// Returns null for unsupported or incomplete input; never repairs raw text.
/// Valid local hunks alone do not establish original refs or write eligibility.
LiteralOriginalDiff? validateLiteralOriginalDiff(CommitDiffFile file) {
if (!_supportedPath(file.oldPath) ||
!_supportedPath(file.newPath) ||
file.isCollapsed == true ||
file.isTooLarge == true ||
(file.isNew && file.isDeleted) ||
(file.isRenamed && (file.isNew || file.isDeleted)) ||
(file.isRenamed
? file.oldPath == file.newPath
: file.oldPath != file.newPath)) {
return null;
}
final text = file.diff;
if (text == null || text.isEmpty) return null;
final raw = text.split('\n');
// Only the final delimiter is framing, never an empty body line.
if (raw.last.isEmpty) raw.removeLast();
final hunks = <DiffHunk>[];
List<DiffLine>? lines;
var oldStart = 0, newStart = 0, oldCount = 0, newCount = 0;
var old = 0, newer = 0, previousOldEnd = -1, previousNewEnd = -1;
var endedOld = false, endedNew = false, markerAllowed = false;
var header = '';
DiffLineType? previousType;

bool finish() {
if (lines == null) return true;
if (old - oldStart != oldCount || newer - newStart != newCount) {
return false;
}
hunks.add(
DiffHunk(
oldStart: oldStart,
newStart: newStart,
header: header,
lines: List<DiffLine>.unmodifiable(lines),
),
);
previousOldEnd = oldStart + (oldCount == 0 ? 1 : oldCount);
previousNewEnd = newStart + (newCount == 0 ? 1 : newCount);
return true;
}

for (final row in raw) {
final match = _header.firstMatch(row);
if (match != null) {
if (!finish()) return null;
final values = [
int.tryParse(match.group(1)!),
int.tryParse(match.group(3)!),
int.tryParse(match.group(2) ?? '1'),
int.tryParse(match.group(4) ?? '1'),
];
if (values.any((v) => v == null || v > _maxCoordinate)) return null;
oldStart = values[0]!;
newStart = values[1]!;
oldCount = values[2]!;
newCount = values[3]!;
if ((oldCount == 0 && newCount == 0) ||
(oldCount > 0 && oldStart == 0) ||
(newCount > 0 && newStart == 0) ||
oldStart + oldCount > _maxCoordinate ||
newStart + newCount > _maxCoordinate ||
(file.isNew &&
(oldStart != 0 ||
oldCount != 0 ||
newStart != 1 ||
hunks.isNotEmpty)) ||
(file.isDeleted &&
(newStart != 0 ||
newCount != 0 ||
oldStart != 1 ||
hunks.isNotEmpty))) {
return null;
}
final effectiveOld = oldStart + (oldCount == 0 ? 1 : 0);
final effectiveNew = newStart + (newCount == 0 ? 1 : 0);
if (previousOldEnd >= 0 &&
(effectiveOld < previousOldEnd ||
effectiveNew < previousNewEnd ||
effectiveOld - previousOldEnd != effectiveNew - previousNewEnd)) {
return null;
}
old = oldStart;
newer = newStart;
header = row;
lines = <DiffLine>[];
markerAllowed = false;
continue;
}
if (lines == null || row.isEmpty) return null;
if (row == r'\ No newline at end of file') {
if (!markerAllowed) return null;
if (previousType != DiffLineType.added) endedOld = true;
if (previousType != DiffLineType.removed) endedNew = true;
markerAllowed = false;
continue;
}
final DiffLineType type;
switch (row[0]) {
case ' ':
type = DiffLineType.context;
case '-':
type = DiffLineType.removed;
case '+':
type = DiffLineType.added;
default:
return null;
}
final usesOld = type != DiffLineType.added,
usesNew = type != DiffLineType.removed;
if ((usesOld && (endedOld || old - oldStart >= oldCount)) ||
(usesNew && (endedNew || newer - newStart >= newCount))) {
return null;
}
lines.add(
DiffLine(
type: type,
text: row.substring(1),
oldLine: usesOld ? old++ : null,
newLine: usesNew ? newer++ : null,
),
);
previousType = type;
markerAllowed = true;
}
if (!finish() || hunks.isEmpty) return null;
return (file: file, hunks: List<DiffHunk>.unmodifiable(hunks));
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:gitlab_models/gitlab_models.dart';

import 'literal_original_diff.dart';

final _lineCode = RegExp(r'^[0-9a-f]{40}_(0|[1-9][0-9]*)_(0|[1-9][0-9]*)$');
final _header = RegExp(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@');

Expand Down Expand Up @@ -30,14 +32,21 @@ typedef _Row = ({DiffLine line, int hunk, String code});
/// Matches literal line codes and visible side coordinates in an original file.
/// Opposite counters in added/removed line codes are not visible coordinates.
/// Only complete, contiguous available hunks can represent a selected span.
List<DiffLine>? originalDiffRange(FileDiff file, DiffNoteLineRange range) {
List<DiffLine>? originalDiffRange(FileDiff file, DiffNoteLineRange range) =>
_originalDiffRange(file.newPath, file.hunks, range);

List<DiffLine>? _originalDiffRange(
String newPath,
List<DiffHunk> hunks,
DiffNoteLineRange range,
) {
if (!supportsOriginalDiffRange(range)) return null;
final hash = sha1.convert(utf8.encode(file.newPath)).toString();
final hash = sha1.convert(utf8.encode(newPath)).toString();
final rows = <_Row>[];
final complete = <bool>[];
final ends = <(int, int)>[];
for (var h = 0; h < file.hunks.length; h++) {
final hunk = file.hunks[h];
for (var h = 0; h < hunks.length; h++) {
final hunk = hunks[h];
var old = hunk.oldStart, newer = hunk.newStart;
for (final line in hunk.lines) {
rows.add((line: line, hunk: h, code: '${hash}_${old}_$newer'));
Expand Down Expand Up @@ -77,8 +86,7 @@ List<DiffLine>? originalDiffRange(FileDiff file, DiffNoteLineRange range) {
final first = span.first.hunk, last = span.last.hunk;
for (var h = first; h <= last; h++) {
if (!complete[h]) return null;
if (h > first &&
ends[h - 1] != (file.hunks[h].oldStart, file.hunks[h].newStart)) {
if (h > first && ends[h - 1] != (hunks[h].oldStart, hunks[h].newStart)) {
return null;
}
}
Expand All @@ -92,3 +100,13 @@ List<DiffLine>? originalDiffRange(FileDiff file, DiffNoteLineRange range) {
row.line,
]);
}

/// Matches review endpoints only after validating the complete literal file.
List<DiffLine>? literalOriginalDiffRange(
CommitDiffFile file,
DiffNoteLineRange range,
) {
final validated = validateLiteralOriginalDiff(file);
if (validated == null) return null;
return _originalDiffRange(file.newPath, validated.hunks, range);
}
178 changes: 178 additions & 0 deletions apps/labfox/test/literal_original_diff_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gitlab_models/gitlab_models.dart';
import 'package:labfox/features/merge_requests/data/literal_original_diff.dart';
import 'package:labfox/features/merge_requests/data/original_diff_range.dart';

const text = '@@ -1 +1 @@\n-old \n+new\n';
CommitDiffFile file(String? diff) => CommitDiffFile(
oldPath: 'lib/a.dart',
newPath: 'lib/a.dart',
isNew: false,
isDeleted: false,
isRenamed: false,
diff: diff,
);

void main() {
for (final (raw, accepted) in [
(text, true),
('@@ -1 +1 @@\n-a\n?ignored\n+b', false),
('@@ -1,2 +1 @@\n-a\n+b', false),
('$text@@ -5 +5 @@\n-a\n+b', true),
]) {
test(
'literal endpoint matching validates all input before selection $accepted $raw',
() {
final hash = sha1.convert(utf8.encode('lib/a.dart')).toString();
final endpoint = DiffNoteRangeEndpoint(
lineCode: '${hash}_2_1',
type: 'new',
newLine: 1,
);
final range = DiffNoteLineRange(start: endpoint, end: endpoint);
final result = literalOriginalDiffRange(file(raw), range);
if (accepted) {
expect(result!.single.newLine, 1);
expect(() => result.clear(), throwsUnsupportedError);
} else {
expect(result, isNull);
}
expect(
literalOriginalDiffRange(
file(raw).copyWith(isCollapsed: true),
range,
),
isNull,
);
},
);
}
for (final raw in [
file('@@ -0,0 +2 @@\n+a').copyWith(isNew: true),
file('@@ -2 +0,0 @@\n-a').copyWith(isDeleted: true),
]) {
test('new/deleted text must begin at the first existing line $raw', () {
expect(validateLiteralOriginalDiff(raw), isNull);
});
}

test(
'retains literal metadata, whitespace, counters and immutable hunks',
() {
final raw = file('@@ -1,2 +1,2 @@ label\n same\r\n-old \n+new\n');
final result = validateLiteralOriginalDiff(raw)!;
expect(identical(result.file, raw), isTrue);
expect(result.hunks.single.header, '@@ -1,2 +1,2 @@ label');
expect(result.hunks.single.lines.map((l) => l.text), [
'same\r',
'old ',
'new',
]);
expect(result.hunks.single.lines.map((l) => (l.oldLine, l.newLine)), [
(1, 1),
(2, null),
(null, 2),
]);
expect(() => result.hunks.clear(), throwsUnsupportedError);
expect(() => result.hunks.single.lines.clear(), throwsUnsupportedError);
},
);
for (final input in [
null,
'',
'Binary files differ',
'preamble\n$text',
'@@ -1 +1 @@garbage\n-a\n+b',
'@@ -01 +1 @@\n-a\n+b',
'@@ -0 +1 @@\n-a\n+b',
'@@ -1,0 +1,0 @@',
'@@ -1,2 +1 @@\n-a\n+b',
'@@ -1 +1 @@\n-a\n+b\n+extra',
'@@ -1 +1 @@\n-a\n\n+b',
'@@ -1 +1 @@\n-a\n?ignored\n+b',
'@@ -1 +1 @@\n-a\n\\junk\n+b',
'@@ -999999999999999999999999999999 +1 @@\n-a\n+b',
'@@ -2147483647,2 +1 @@\n-a\n-b\n+c',
'$text@@ -1 +1 @@\n-a\n+b',
'$text@@ -0,0 +2 @@\n+b',
'$text@@ -3 +4 @@\n-a\n+b',
'$text@@ -2 +2 @@\n-a\n+b\ntrailer',
'@@ -1 +1 @@\n\\ No newline at end of file\n-a\n+b',
'@@ -1 +1 @@\n-a\n\\ No newline at end of file\n\\ No newline at end of file\n+b',
'@@ -1,2 +1 @@\n-a\n\\ No newline at end of file\n-b\n+c',
]) {
test('rejects malformed or incomplete literal input $input', () {
expect(validateLiteralOriginalDiff(file(input)), isNull);
});
}
for (final change in [
file(text).copyWith(isCollapsed: true),
file(text).copyWith(isTooLarge: true),
file(text).copyWith(isNew: true, isDeleted: true),
file(text).copyWith(isRenamed: true),
file(text).copyWith(newPath: 'other'),
file(text).copyWith(oldPath: ''),
file(text).copyWith(newPath: '/dev/null'),
file(text).copyWith(oldPath: '../secret'),
file(text).copyWith(newPath: 'lib/../a'),
file(text).copyWith(newPath: 'lib//a'),
file(text).copyWith(newPath: 'lib/a\u0000'),
file(text).copyWith(newPath: 'lib/a\n'),
file(text).copyWith(isNew: true, isRenamed: true),
file(text).copyWith(isDeleted: true, isRenamed: true),
file(text).copyWith(isNew: true),
file(text).copyWith(isDeleted: true),
]) {
test('rejects contradictory or unsupported metadata $change', () {
expect(validateLiteralOriginalDiff(change), isNull);
});
}
for (final input in [
text,
text.substring(0, text.length - 1),
'$text@@ -2 +2 @@\n-a\n+b',
'$text@@ -5 +5 @@\n-a\n+b',
'@@ -0,0 +1,2 @@\n+a\n+b',
'@@ -1,2 +0,0 @@\n-a\n-b',
'@@ -5,0 +6,2 @@\n+a\n+b\n@@ -6 +8 @@\n-a\n+b',
'@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+b\n\\ No newline at end of file\n',
'@@ -1 +1 @@\n same\n\\ No newline at end of file',
]) {
test('accepts complete literal hunks $input', () {
expect(validateLiteralOriginalDiff(file(input)), isNotNull);
});
}
test('new deleted and renamed metadata matches literal sides', () {
expect(
validateLiteralOriginalDiff(
file('@@ -0,0 +1 @@\n+a').copyWith(isNew: true),
),
isNotNull,
);
expect(
validateLiteralOriginalDiff(
file('@@ -1 +0,0 @@\n-a').copyWith(isDeleted: true),
),
isNotNull,
);
expect(
validateLiteralOriginalDiff(
file(text).copyWith(isRenamed: true, newPath: 'other'),
),
isNotNull,
);
});
test(
'unknown omission metadata stays unknown without synthesizing flags',
() {
final raw = file(text);
final result = validateLiteralOriginalDiff(raw)!;
expect(result.file.isCollapsed, isNull);
expect(result.file.isTooLarge, isNull);
expect(result.file.diff, text);
},
);
}
Loading
Loading