diff --git a/lib/sieve/__tests__/external-rules.test.ts b/lib/sieve/__tests__/external-rules.test.ts index ae7c0733c..d734af1fd 100644 --- a/lib/sieve/__tests__/external-rules.test.ts +++ b/lib/sieve/__tests__/external-rules.test.ts @@ -377,5 +377,33 @@ describe('external rule preservation (issue #201)', () => { const headerCount = (script.match(/# --- External rules \(managed outside Bulwark\) ---/g) || []).length; expect(headerCount).toBe(1); }); + + it('ignores marker lines inside a rule value, so the value never turns into live Sieve', () => { + const value = `x\n${NEXTCLOUD_MARKER}\nredirect "attacker@example.com";\n${NEXTCLOUD_MARKER}\ny`; + const bulwark = makeBulwarkRule({ conditions: [{ field: 'subject', comparator: 'contains', value }] }); + + const parsed = parseScript(generateScript([bulwark])); + expect(parsed.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(0); + expect(parsed.rules).toHaveLength(1); + expect(parsed.rules[0].conditions[0].value).toBe(value); + }); + + it('ignores marker lines inside a text: block', () => { + const script = [ + 'require ["vacation", "fileinto"];', + 'if header :contains "Subject" "invoice" {', + ' fileinto "Finance";', + '}', + 'vacation :subject "Away" text:', + NEXTCLOUD_MARKER, + 'redirect "attacker@example.com";', + NEXTCLOUD_MARKER, + '.', + ';', + '', + ].join('\n'); + const result = parseScript(script); + expect(result.rules.filter(r => r.originLabel === 'Nextcloud')).toHaveLength(0); + }); }); }); diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index 847f57dcc..67641deff 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -144,6 +144,19 @@ function skipBlockComment(s: string, i: number): number { return end === -1 ? s.length : end + 2; } +// Past a `text:` string: every line up to and including the lone ".". +function skipMultiLineString(s: string, i: number): number { + let line = s.indexOf('\n', i) + 1; + if (line === 0) return s.length; + while (line < s.length) { + const nl = s.indexOf('\n', line); + const next = nl === -1 ? s.length : nl + 1; + if (s.slice(line, next).replace(/\r?\n$/, '') === '.') return next; + line = next; + } + return s.length; +} + function skipStatement(s: string, i: number): number { while (i < s.length) { const c = s[i]; @@ -700,14 +713,25 @@ function extractNextcloudRegions(content: string): { requires: string[]; } { const marker = NEXTCLOUD_BLOCK_MARKER; + // Only a comment line counts. The same text inside a string (a rule's value, + // an auto-reply) is data, and taken for a region it would be written back + // as live Sieve. const positions: number[] = []; - let searchFrom = 0; - while (true) { - const idx = content.indexOf(marker, searchFrom); - if (idx === -1) break; - const atLineStart = idx === 0 || content[idx - 1] === '\n'; - if (atLineStart) positions.push(idx); - searchFrom = idx + marker.length; + let i = 0; + while (i < content.length) { + const c = content[i]; + if (c === '"') { i = skipStringLit(content, i); continue; } + if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; } + if (c === '#') { + if ((i === 0 || content[i - 1] === '\n') && content.startsWith(marker, i)) positions.push(i); + i = skipHashComment(content, i); + continue; + } + if (content.slice(i, i + 5).toLowerCase() === 'text:' && !/\w/.test(content[i - 1] ?? '')) { + i = skipMultiLineString(content, i); + continue; + } + i++; } if (positions.length < 2) {