[8.16] [ResponseOps] [Alerting] Handle invalid RRule params and prevent infinite looping (#205650) (#205831)

# Backport

This will backport the following commits from `main` to `8.16`:
- [[ResponseOps] [Alerting] Handle invalid RRule params and prevent
infinite looping
(#205650)](https://github.com/elastic/kibana/pull/205650)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Zacqary Adam
Xeper","email":"Zacqary@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-01-07T19:32:43Z","message":"[ResponseOps]
[Alerting] Handle invalid RRule params and prevent infinite looping
(#205650)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/205558\r\n\r\nUpdates the RRule
library to correctly handle some scenarios with\r\ninvalid parameters
that would either cause it to return strange\r\nrecurrence data or to
infinitely loop. Specifically:\r\n\r\n- On `RRule` object creation,
removes and ignores any `bymonth`,\r\n`bymonthday`, `byweekday`, or
`byyearday` value that's out of bounds,\r\ne.g. less than 0 or greater
than the number of possible months, days,\r\nweekdays, etc.\r\n-
Successfully ignores cases of `BYMONTH=2, BYMONTHDAY=30`
(February\r\n30th), an input that's complicated to invalidate but still
won't ever\r\noccur\r\n\r\nAllowing these values to go unhandled led to
unpredictable behavior. The\r\nRRule library uses Moment.js to compare
dates, but Moment.js months,\r\ndays, and other values generally start
at `0` while RRule values start\r\nat `1`. That led to several
circumstances where we passed Moment.js a\r\nvalue of `-1`, which
Moment.js interpreted as moving to the\r\n***previous*** year, month, or
other period of time.\r\n\r\nAt worst, this could cause an infinite loop
because the RRule library\r\nwas constantly iterating through the wrong
year, never reaching the date\r\nit was supposed to end on.\r\n\r\nIn
addition to making the RRule library more able to handle these
cases,\r\nthis PR also gives it a hard 100,000 iteration limit to
prevent any\r\npossible infinite loops we've missed.\r\n\r\nLastly, the
Snooze Schedule APIs also come with additional validation
to\r\nhopefully prevent out of bounds dates from ever being
set.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi
<117571355+js-jankisalvi@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi <jankigaurav.salvi@elastic.co>\r\nCo-authored-by: adcoelho
<antonio.coelho@elastic.co>","sha":"b30210929be0824f684f0b7d9d13bc936c1cbd22","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:ResponseOps","v9.0.0","Feature:Alerting/RulesFramework","backport:version","v8.18.0","v8.16.3","v8.17.1"],"number":205650,"url":"https://github.com/elastic/kibana/pull/205650","mergeCommit":{"message":"[ResponseOps]
[Alerting] Handle invalid RRule params and prevent infinite looping
(#205650)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/205558\r\n\r\nUpdates the RRule
library to correctly handle some scenarios with\r\ninvalid parameters
that would either cause it to return strange\r\nrecurrence data or to
infinitely loop. Specifically:\r\n\r\n- On `RRule` object creation,
removes and ignores any `bymonth`,\r\n`bymonthday`, `byweekday`, or
`byyearday` value that's out of bounds,\r\ne.g. less than 0 or greater
than the number of possible months, days,\r\nweekdays, etc.\r\n-
Successfully ignores cases of `BYMONTH=2, BYMONTHDAY=30`
(February\r\n30th), an input that's complicated to invalidate but still
won't ever\r\noccur\r\n\r\nAllowing these values to go unhandled led to
unpredictable behavior. The\r\nRRule library uses Moment.js to compare
dates, but Moment.js months,\r\ndays, and other values generally start
at `0` while RRule values start\r\nat `1`. That led to several
circumstances where we passed Moment.js a\r\nvalue of `-1`, which
Moment.js interpreted as moving to the\r\n***previous*** year, month, or
other period of time.\r\n\r\nAt worst, this could cause an infinite loop
because the RRule library\r\nwas constantly iterating through the wrong
year, never reaching the date\r\nit was supposed to end on.\r\n\r\nIn
addition to making the RRule library more able to handle these
cases,\r\nthis PR also gives it a hard 100,000 iteration limit to
prevent any\r\npossible infinite loops we've missed.\r\n\r\nLastly, the
Snooze Schedule APIs also come with additional validation
to\r\nhopefully prevent out of bounds dates from ever being
set.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi
<117571355+js-jankisalvi@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi <jankigaurav.salvi@elastic.co>\r\nCo-authored-by: adcoelho
<antonio.coelho@elastic.co>","sha":"b30210929be0824f684f0b7d9d13bc936c1cbd22"}},"sourceBranch":"main","suggestedTargetBranches":["8.16","8.17"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/205650","number":205650,"mergeCommit":{"message":"[ResponseOps]
[Alerting] Handle invalid RRule params and prevent infinite looping
(#205650)\n\n## Summary\r\n\r\nCloses
https://github.com/elastic/kibana/issues/205558\r\n\r\nUpdates the RRule
library to correctly handle some scenarios with\r\ninvalid parameters
that would either cause it to return strange\r\nrecurrence data or to
infinitely loop. Specifically:\r\n\r\n- On `RRule` object creation,
removes and ignores any `bymonth`,\r\n`bymonthday`, `byweekday`, or
`byyearday` value that's out of bounds,\r\ne.g. less than 0 or greater
than the number of possible months, days,\r\nweekdays, etc.\r\n-
Successfully ignores cases of `BYMONTH=2, BYMONTHDAY=30`
(February\r\n30th), an input that's complicated to invalidate but still
won't ever\r\noccur\r\n\r\nAllowing these values to go unhandled led to
unpredictable behavior. The\r\nRRule library uses Moment.js to compare
dates, but Moment.js months,\r\ndays, and other values generally start
at `0` while RRule values start\r\nat `1`. That led to several
circumstances where we passed Moment.js a\r\nvalue of `-1`, which
Moment.js interpreted as moving to the\r\n***previous*** year, month, or
other period of time.\r\n\r\nAt worst, this could cause an infinite loop
because the RRule library\r\nwas constantly iterating through the wrong
year, never reaching the date\r\nit was supposed to end on.\r\n\r\nIn
addition to making the RRule library more able to handle these
cases,\r\nthis PR also gives it a hard 100,000 iteration limit to
prevent any\r\npossible infinite loops we've missed.\r\n\r\nLastly, the
Snooze Schedule APIs also come with additional validation
to\r\nhopefully prevent out of bounds dates from ever being
set.\r\n\r\n### Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi
<117571355+js-jankisalvi@users.noreply.github.com>\r\nCo-authored-by:
Janki Salvi <jankigaurav.salvi@elastic.co>\r\nCo-authored-by: adcoelho
<antonio.coelho@elastic.co>","sha":"b30210929be0824f684f0b7d9d13bc936c1cbd22"}},{"branch":"8.x","label":"v8.18.0","labelRegex":"^v8.18.0$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/205803","number":205803,"state":"MERGED","mergeCommit":{"sha":"a02fcb232faed2f385ce9b97fbdb323ccbf8ca45","message":"[8.x]
[ResponseOps] [Alerting] Handle invalid RRule params and prevent
infinite looping (#205650) (#205803)\n\n# Backport\n\nThis will backport
the following commits from `main` to `8.x`:\n- [[ResponseOps] [Alerting]
Handle invalid RRule params and prevent\ninfinite
looping\n(#205650)](https://github.com/elastic/kibana/pull/205650)\n\n<!---
Backport version: 9.4.3 -->\n\n### Questions ?\nPlease refer to the
[Backport
tool\ndocumentation](https://github.com/sqren/backport)\n\n<!--BACKPORT
[{\"author\":{\"name\":\"Zacqary
Adam\nXeper\",\"email\":\"Zacqary@users.noreply.github.com\"},\"sourceCommit\":{\"committedDate\":\"2025-01-07T19:32:43Z\",\"message\":\"[ResponseOps]\n[Alerting]
Handle invalid RRule params and prevent infinite
looping\n(#205650)\\n\\n##
Summary\\r\\n\\r\\nCloses\nhttps://github.com/elastic/kibana/issues/205558\\r\\n\\r\\nUpdates
the RRule\nlibrary to correctly handle some scenarios with\\r\\ninvalid
parameters\nthat would either cause it to return strange\\r\\nrecurrence
data or to\ninfinitely loop. Specifically:\\r\\n\\r\\n- On `RRule`
object creation,\nremoves and ignores any `bymonth`,\\r\\n`bymonthday`,
`byweekday`, or\n`byyearday` value that's out of bounds,\\r\\ne.g. less
than 0 or greater\nthan the number of possible months,
days,\\r\\nweekdays, etc.\\r\\n-\nSuccessfully ignores cases of
`BYMONTH=2, BYMONTHDAY=30`\n(February\\r\\n30th), an input that's
complicated to invalidate but still\nwon't
ever\\r\\noccur\\r\\n\\r\\nAllowing these values to go unhandled led
to\nunpredictable behavior. The\\r\\nRRule library uses Moment.js to
compare\ndates, but Moment.js months,\\r\\ndays, and other values
generally start\nat `0` while RRule values start\\r\\nat `1`. That led
to several\ncircumstances where we passed Moment.js a\\r\\nvalue of
`-1`, which\nMoment.js interpreted as moving to the\\r\\n***previous***
year, month, or\nother period of time.\\r\\n\\r\\nAt worst, this could
cause an infinite loop\nbecause the RRule library\\r\\nwas constantly
iterating through the wrong\nyear, never reaching the date\\r\\nit was
supposed to end on.\\r\\n\\r\\nIn\naddition to making the RRule library
more able to handle these\ncases,\\r\\nthis PR also gives it a hard
100,000 iteration limit to\nprevent any\\r\\npossible infinite loops
we've missed.\\r\\n\\r\\nLastly, the\nSnooze Schedule APIs also come
with additional validation\nto\\r\\nhopefully prevent out of bounds
dates from ever being\nset.\\r\\n\\r\\n### Checklist\\r\\n\\r\\n- [x]
[Unit
or\nfunctional\\r\\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\\r\\nwere\nupdated
or added to match the most
common\nscenarios\\r\\n\\r\\n---------\\r\\n\\r\\nCo-authored-by:
kibanamachine\n<42973632+kibanamachine@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi\n<117571355+js-jankisalvi@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi <jankigaurav.salvi@elastic.co>\\r\\nCo-authored-by:
adcoelho\n<antonio.coelho@elastic.co>\",\"sha\":\"b30210929be0824f684f0b7d9d13bc936c1cbd22\",\"branchLabelMapping\":{\"^v9.0.0$\":\"main\",\"^v8.18.0$\":\"8.x\",\"^v(\\\\d+).(\\\\d+).\\\\d+$\":\"$1.$2\"}},\"sourcePullRequest\":{\"labels\":[\"release_note:fix\",\"Team:ResponseOps\",\"v9.0.0\",\"Feature:Alerting/RulesFramework\",\"backport:version\",\"v8.18.0\",\"v8.16.3\",\"v8.17.1\"],\"title\":\"[ResponseOps]\n[Alerting]
Handle invalid RRule params and prevent
infinite\nlooping\",\"number\":205650,\"url\":\"https://github.com/elastic/kibana/pull/205650\",\"mergeCommit\":{\"message\":\"[ResponseOps]\n[Alerting]
Handle invalid RRule params and prevent infinite
looping\n(#205650)\\n\\n##
Summary\\r\\n\\r\\nCloses\nhttps://github.com/elastic/kibana/issues/205558\\r\\n\\r\\nUpdates
the RRule\nlibrary to correctly handle some scenarios with\\r\\ninvalid
parameters\nthat would either cause it to return strange\\r\\nrecurrence
data or to\ninfinitely loop. Specifically:\\r\\n\\r\\n- On `RRule`
object creation,\nremoves and ignores any `bymonth`,\\r\\n`bymonthday`,
`byweekday`, or\n`byyearday` value that's out of bounds,\\r\\ne.g. less
than 0 or greater\nthan the number of possible months,
days,\\r\\nweekdays, etc.\\r\\n-\nSuccessfully ignores cases of
`BYMONTH=2, BYMONTHDAY=30`\n(February\\r\\n30th), an input that's
complicated to invalidate but still\nwon't
ever\\r\\noccur\\r\\n\\r\\nAllowing these values to go unhandled led
to\nunpredictable behavior. The\\r\\nRRule library uses Moment.js to
compare\ndates, but Moment.js months,\\r\\ndays, and other values
generally start\nat `0` while RRule values start\\r\\nat `1`. That led
to several\ncircumstances where we passed Moment.js a\\r\\nvalue of
`-1`, which\nMoment.js interpreted as moving to the\\r\\n***previous***
year, month, or\nother period of time.\\r\\n\\r\\nAt worst, this could
cause an infinite loop\nbecause the RRule library\\r\\nwas constantly
iterating through the wrong\nyear, never reaching the date\\r\\nit was
supposed to end on.\\r\\n\\r\\nIn\naddition to making the RRule library
more able to handle these\ncases,\\r\\nthis PR also gives it a hard
100,000 iteration limit to\nprevent any\\r\\npossible infinite loops
we've missed.\\r\\n\\r\\nLastly, the\nSnooze Schedule APIs also come
with additional validation\nto\\r\\nhopefully prevent out of bounds
dates from ever being\nset.\\r\\n\\r\\n### Checklist\\r\\n\\r\\n- [x]
[Unit
or\nfunctional\\r\\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\\r\\nwere\nupdated
or added to match the most
common\nscenarios\\r\\n\\r\\n---------\\r\\n\\r\\nCo-authored-by:
kibanamachine\n<42973632+kibanamachine@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi\n<117571355+js-jankisalvi@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi <jankigaurav.salvi@elastic.co>\\r\\nCo-authored-by:
adcoelho\n<antonio.coelho@elastic.co>\",\"sha\":\"b30210929be0824f684f0b7d9d13bc936c1cbd22\"}},\"sourceBranch\":\"main\",\"suggestedTargetBranches\":[\"8.x\",\"8.16\",\"8.17\"],\"targetPullRequestStates\":[{\"branch\":\"main\",\"label\":\"v9.0.0\",\"branchLabelMappingKey\":\"^v9.0.0$\",\"isSourceBranch\":true,\"state\":\"MERGED\",\"url\":\"https://github.com/elastic/kibana/pull/205650\",\"number\":205650,\"mergeCommit\":{\"message\":\"[ResponseOps]\n[Alerting]
Handle invalid RRule params and prevent infinite
looping\n(#205650)\\n\\n##
Summary\\r\\n\\r\\nCloses\nhttps://github.com/elastic/kibana/issues/205558\\r\\n\\r\\nUpdates
the RRule\nlibrary to correctly handle some scenarios with\\r\\ninvalid
parameters\nthat would either cause it to return strange\\r\\nrecurrence
data or to\ninfinitely loop. Specifically:\\r\\n\\r\\n- On `RRule`
object creation,\nremoves and ignores any `bymonth`,\\r\\n`bymonthday`,
`byweekday`, or\n`byyearday` value that's out of bounds,\\r\\ne.g. less
than 0 or greater\nthan the number of possible months,
days,\\r\\nweekdays, etc.\\r\\n-\nSuccessfully ignores cases of
`BYMONTH=2, BYMONTHDAY=30`\n(February\\r\\n30th), an input that's
complicated to invalidate but still\nwon't
ever\\r\\noccur\\r\\n\\r\\nAllowing these values to go unhandled led
to\nunpredictable behavior. The\\r\\nRRule library uses Moment.js to
compare\ndates, but Moment.js months,\\r\\ndays, and other values
generally start\nat `0` while RRule values start\\r\\nat `1`. That led
to several\ncircumstances where we passed Moment.js a\\r\\nvalue of
`-1`, which\nMoment.js interpreted as moving to the\\r\\n***previous***
year, month, or\nother period of time.\\r\\n\\r\\nAt worst, this could
cause an infinite loop\nbecause the RRule library\\r\\nwas constantly
iterating through the wrong\nyear, never reaching the date\\r\\nit was
supposed to end on.\\r\\n\\r\\nIn\naddition to making the RRule library
more able to handle these\ncases,\\r\\nthis PR also gives it a hard
100,000 iteration limit to\nprevent any\\r\\npossible infinite loops
we've missed.\\r\\n\\r\\nLastly, the\nSnooze Schedule APIs also come
with additional validation\nto\\r\\nhopefully prevent out of bounds
dates from ever being\nset.\\r\\n\\r\\n### Checklist\\r\\n\\r\\n- [x]
[Unit
or\nfunctional\\r\\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\\r\\nwere\nupdated
or added to match the most
common\nscenarios\\r\\n\\r\\n---------\\r\\n\\r\\nCo-authored-by:
kibanamachine\n<42973632+kibanamachine@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi\n<117571355+js-jankisalvi@users.noreply.github.com>\\r\\nCo-authored-by:\nJanki
Salvi <jankigaurav.salvi@elastic.co>\\r\\nCo-authored-by:
adcoelho\n<antonio.coelho@elastic.co>\",\"sha\":\"b30210929be0824f684f0b7d9d13bc936c1cbd22\"}},{\"branch\":\"8.x\",\"label\":\"v8.18.0\",\"branchLabelMappingKey\":\"^v8.18.0$\",\"isSourceBranch\":false,\"state\":\"NOT_CREATED\"},{\"branch\":\"8.16\",\"label\":\"v8.16.3\",\"branchLabelMappingKey\":\"^v(\\\\d+).(\\\\d+).\\\\d+$\",\"isSourceBranch\":false,\"state\":\"NOT_CREATED\"},{\"branch\":\"8.17\",\"label\":\"v8.17.1\",\"branchLabelMappingKey\":\"^v(\\\\d+).(\\\\d+).\\\\d+$\",\"isSourceBranch\":false,\"state\":\"NOT_CREATED\"}]}]\nBACKPORT-->\n\nCo-authored-by:
Zacqary Adam Xeper
<Zacqary@users.noreply.github.com>"}},{"branch":"8.16","label":"v8.16.3","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.1","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: Zacqary Adam Xeper <Zacqary@users.noreply.github.com>
This commit is contained in:
Mike Côté 2025-01-07 22:44:44 -05:00 committed by GitHub
parent d57842ab23
commit 98100987d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 614 additions and 67 deletions

View file

@ -7,6 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { RRule, Frequency, Weekday } from './rrule';
export type { Options } from './rrule';
export declare type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
export { RRule } from './rrule';
export type { Options, WeekdayStr } from './types';
export { Frequency, Weekday } from './types';

View file

@ -8,7 +8,8 @@
*/
import sinon from 'sinon';
import { RRule, Frequency, Weekday } from './rrule';
import { RRule } from './rrule';
import { Frequency, Weekday } from './types';
const DATE_2019 = '2019-01-01T00:00:00.000Z';
const DATE_2019_DECEMBER_19 = '2019-12-19T00:00:00.000Z';
@ -730,6 +731,228 @@ describe('RRule', () => {
]
`);
});
it('ignores invalid byweekday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.WEEKLY,
interval: 1,
tzid: 'UTC',
byweekday: [Weekday.TH, 0, -2],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2019-12-26T00:00:00.000Z,
2020-01-02T00:00:00.000Z,
2020-01-09T00:00:00.000Z,
2020-01-16T00:00:00.000Z,
2020-01-23T00:00:00.000Z,
2020-01-30T00:00:00.000Z,
2020-02-06T00:00:00.000Z,
2020-02-13T00:00:00.000Z,
2020-02-20T00:00:00.000Z,
2020-02-27T00:00:00.000Z,
2020-03-05T00:00:00.000Z,
2020-03-12T00:00:00.000Z,
2020-03-19T00:00:00.000Z,
]
`);
const rule2 = new RRule({
dtstart: new Date(DATE_2019),
freq: Frequency.WEEKLY,
interval: 1,
tzid: 'UTC',
byweekday: [Weekday.SA, Weekday.SU, Weekday.MO, 0],
});
expect(rule2.all(9)).toMatchInlineSnapshot(`
Array [
2019-01-05T00:00:00.000Z,
2019-01-06T00:00:00.000Z,
2019-01-07T00:00:00.000Z,
2019-01-12T00:00:00.000Z,
2019-01-13T00:00:00.000Z,
2019-01-14T00:00:00.000Z,
2019-01-19T00:00:00.000Z,
2019-01-20T00:00:00.000Z,
2019-01-21T00:00:00.000Z,
]
`);
});
});
describe('bymonth', () => {
it('works with yearly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-02-19T00:00:00.000Z,
2020-05-19T00:00:00.000Z,
2021-02-19T00:00:00.000Z,
2021-05-19T00:00:00.000Z,
2022-02-19T00:00:00.000Z,
2022-05-19T00:00:00.000Z,
2023-02-19T00:00:00.000Z,
2023-05-19T00:00:00.000Z,
2024-02-19T00:00:00.000Z,
2024-05-19T00:00:00.000Z,
2025-02-19T00:00:00.000Z,
2025-05-19T00:00:00.000Z,
2026-02-19T00:00:00.000Z,
2026-05-19T00:00:00.000Z,
]
`);
});
it('ignores invalid bymonth values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [0],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2020-12-19T00:00:00.000Z,
2021-12-19T00:00:00.000Z,
2022-12-19T00:00:00.000Z,
2023-12-19T00:00:00.000Z,
2024-12-19T00:00:00.000Z,
2025-12-19T00:00:00.000Z,
2026-12-19T00:00:00.000Z,
2027-12-19T00:00:00.000Z,
2028-12-19T00:00:00.000Z,
2029-12-19T00:00:00.000Z,
2030-12-19T00:00:00.000Z,
2031-12-19T00:00:00.000Z,
2032-12-19T00:00:00.000Z,
]
`);
});
});
describe('bymonthday', () => {
it('works with monthly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.MONTHLY,
interval: 1,
tzid: 'UTC',
bymonthday: [1, 15],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-01-01T00:00:00.000Z,
2020-01-15T00:00:00.000Z,
2020-02-01T00:00:00.000Z,
2020-02-15T00:00:00.000Z,
2020-03-01T00:00:00.000Z,
2020-03-15T00:00:00.000Z,
2020-04-01T00:00:00.000Z,
2020-04-15T00:00:00.000Z,
2020-05-01T00:00:00.000Z,
2020-05-15T00:00:00.000Z,
2020-06-01T00:00:00.000Z,
2020-06-15T00:00:00.000Z,
2020-07-01T00:00:00.000Z,
2020-07-15T00:00:00.000Z,
]
`);
});
it('ignores invalid bymonthday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.MONTHLY,
interval: 1,
tzid: 'UTC',
bymonthday: [0, -1, 32],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2019-12-19T00:00:00.000Z,
2020-01-19T00:00:00.000Z,
2020-02-19T00:00:00.000Z,
2020-03-19T00:00:00.000Z,
2020-04-19T00:00:00.000Z,
2020-05-19T00:00:00.000Z,
2020-06-19T00:00:00.000Z,
2020-07-19T00:00:00.000Z,
2020-08-19T00:00:00.000Z,
2020-09-19T00:00:00.000Z,
2020-10-19T00:00:00.000Z,
2020-11-19T00:00:00.000Z,
2020-12-19T00:00:00.000Z,
2021-01-19T00:00:00.000Z,
]
`);
});
});
describe('bymonth, bymonthday', () => {
it('works with yearly frequency', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
bymonthday: [8],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-02-08T00:00:00.000Z,
2020-05-08T00:00:00.000Z,
2021-02-08T00:00:00.000Z,
2021-05-08T00:00:00.000Z,
2022-02-08T00:00:00.000Z,
2022-05-08T00:00:00.000Z,
2023-02-08T00:00:00.000Z,
2023-05-08T00:00:00.000Z,
2024-02-08T00:00:00.000Z,
2024-05-08T00:00:00.000Z,
2025-02-08T00:00:00.000Z,
2025-05-08T00:00:00.000Z,
2026-02-08T00:00:00.000Z,
2026-05-08T00:00:00.000Z,
]
`);
});
it('ignores valid dates that do not exist e.g. February 30th', () => {
const rule = new RRule({
dtstart: new Date(DATE_2019_DECEMBER_19),
freq: Frequency.YEARLY,
interval: 1,
tzid: 'UTC',
bymonth: [2, 5],
bymonthday: [30],
});
expect(rule.all(14)).toMatchInlineSnapshot(`
Array [
2020-05-30T00:00:00.000Z,
2021-05-30T00:00:00.000Z,
2022-05-30T00:00:00.000Z,
2023-05-30T00:00:00.000Z,
2024-05-30T00:00:00.000Z,
2025-05-30T00:00:00.000Z,
2026-05-30T00:00:00.000Z,
2027-05-30T00:00:00.000Z,
2028-05-30T00:00:00.000Z,
2029-05-30T00:00:00.000Z,
2030-05-30T00:00:00.000Z,
2031-05-30T00:00:00.000Z,
2032-05-30T00:00:00.000Z,
2033-05-30T00:00:00.000Z,
]
`);
});
});
describe('byhour, byminute, bysecond', () => {
@ -844,6 +1067,30 @@ describe('RRule', () => {
]
`);
});
it('ignores invalid byyearday values', () => {
const rule = new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.YEARLY,
byyearday: [0, -1],
interval: 1,
tzid: 'UTC',
});
expect(rule.all(10)).toMatchInlineSnapshot(`
Array [
2020-01-01T00:00:00.000Z,
2021-01-01T00:00:00.000Z,
2022-01-01T00:00:00.000Z,
2023-01-01T00:00:00.000Z,
2024-01-01T00:00:00.000Z,
2025-01-01T00:00:00.000Z,
2026-01-01T00:00:00.000Z,
2027-01-01T00:00:00.000Z,
2028-01-01T00:00:00.000Z,
2029-01-01T00:00:00.000Z,
]
`);
});
});
describe('error handling', () => {
@ -872,5 +1119,33 @@ describe('RRule', () => {
`"Cannot create RRule: until is an invalid date"`
);
});
it('throws an error on an interval of 0', () => {
const testFn = () =>
new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.HOURLY,
interval: 0,
tzid: 'UTC',
});
expect(testFn).toThrowErrorMatchingInlineSnapshot(
`"Cannot create RRule: interval must be greater than 0"`
);
});
it('throws an error when exceeding the iteration limit', () => {
const testFn = () => {
const rule = new RRule({
dtstart: new Date(DATE_2020),
freq: Frequency.YEARLY,
byyearday: [1],
interval: 1,
tzid: 'UTC',
});
rule.all(100001);
};
expect(testFn).toThrowErrorMatchingInlineSnapshot(`"RRule iteration limit exceeded"`);
});
});
});

View file

@ -7,57 +7,16 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import moment, { Moment } from 'moment-timezone';
import moment, { type Moment } from 'moment-timezone';
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
}
export enum Weekday {
MO = 1,
TU = 2,
WE = 3,
TH = 4,
FR = 5,
SA = 6,
SU = 7,
}
export type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
interface IterOptions {
refDT: Moment;
wkst?: Weekday | number | null;
byyearday?: number[] | null;
bymonth?: number[] | null;
bysetpos?: number[] | null;
bymonthday?: number[] | null;
byweekday?: Weekday[] | null;
byhour?: number[] | null;
byminute?: number[] | null;
bysecond?: number[] | null;
}
type Options = Omit<IterOptions, 'refDT'> & {
dtstart: Date;
freq?: Frequency;
interval?: number;
until?: Date | null;
count?: number;
tzid: string;
};
import { Frequency, Weekday, type WeekdayStr, type Options, type IterOptions } from './types';
import { sanitizeOptions } from './sanitize';
type ConstructorOptions = Omit<Options, 'byweekday' | 'wkst'> & {
byweekday?: Array<string | number> | null;
wkst?: Weekday | WeekdayStr | number | null;
};
export type { ConstructorOptions as Options };
const ISO_WEEKDAYS = [
Weekday.MO,
Weekday.TU,
@ -73,19 +32,15 @@ type AllResult = Date[] & {
};
const ALL_LIMIT = 10000;
const TIMEOUT_LIMIT = 100000;
export class RRule {
private options: Options;
constructor(options: ConstructorOptions) {
this.options = options as Options;
if (isNaN(options.dtstart.getTime())) {
throw new Error('Cannot create RRule: dtstart is an invalid date');
}
if (options.until && isNaN(options.until.getTime())) {
throw new Error('Cannot create RRule: until is an invalid date');
}
this.options = sanitizeOptions(options as Options);
if (typeof options.wkst === 'string') {
this.options.wkst = Weekday[options.wkst];
if (!this.options.wkst) delete this.options.wkst;
}
const weekdayParseResult = parseByWeekdayPos(options.byweekday);
if (weekdayParseResult) {
@ -111,12 +66,17 @@ export class RRule {
.toDate();
const nextRecurrences: Moment[] = [];
let iters = 0;
while (
(!count && !until) ||
(count && yieldedRecurrenceCount < count) ||
(until && current.getTime() < new Date(until).getTime())
(until && current.getTime() < until.getTime())
) {
iters++;
if (iters > TIMEOUT_LIMIT) {
throw new Error('RRule iteration limit exceeded');
}
const next = nextRecurrences.shift()?.toDate();
if (next) {
current = next;
@ -305,6 +265,7 @@ const getYearOfRecurrences = function ({
return getMonthOfRecurrences({
refDT: currentMonth,
wkst,
bymonth,
bymonthday,
byweekday,
byhour,
@ -319,6 +280,7 @@ const getYearOfRecurrences = function ({
return derivedByyearday.flatMap((dayOfYear) => {
const currentDate = moment(refDT).dayOfYear(dayOfYear);
if (currentDate.year() !== refDT.year()) return [];
if (!derivedByweekday.includes(currentDate.isoWeekday())) return [];
return getDayOfRecurrences({ refDT: currentDate, byhour, byminute, bysecond });
});
@ -337,7 +299,7 @@ const getMonthOfRecurrences = function ({
}: IterOptions) {
const derivedByweekday = byweekday ?? ISO_WEEKDAYS;
const currentMonth = refDT.month();
if (bymonth && !bymonth.includes(currentMonth)) return [];
if (bymonth && !bymonth.includes(currentMonth + 1)) return [];
let derivedBymonthday = bymonthday ?? [refDT.date()];
if (bysetpos) {
@ -384,6 +346,7 @@ const getMonthOfRecurrences = function ({
return derivedBymonthday.flatMap((date) => {
const currentDate = moment(refDT).date(date);
if (bymonth && !bymonth.includes(currentDate.month() + 1)) return [];
if (!derivedByweekday.includes(currentDate.isoWeekday())) return [];
return getDayOfRecurrences({ refDT: currentDate, byhour, byminute, bysecond });
});

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { sanitizeOptions } from './sanitize';
import { Weekday, Frequency, type Options } from './types';
describe('sanitizeOptions', () => {
const options: Options = {
wkst: Weekday.MO,
byyearday: [1, 2, 3],
bymonth: [1],
bysetpos: [1],
bymonthday: [1],
byweekday: [Weekday.MO],
byhour: [1],
byminute: [1],
bysecond: [1],
dtstart: new Date('September 3, 1998 03:24:00'),
freq: Frequency.YEARLY,
interval: 1,
until: new Date('February 25, 2022 03:24:00'),
count: 3,
tzid: 'foobar',
};
it('happy path', () => {
expect(sanitizeOptions(options)).toEqual(options);
});
it('throws an error when dtstart is missing', () => {
// @ts-expect-error
expect(() => sanitizeOptions({ ...options, dtstart: null })).toThrowError(
'Cannot create RRule: dtstart is required'
);
});
it('throws an error when tzid is missing', () => {
expect(() => sanitizeOptions({ ...options, tzid: '' })).toThrowError(
'Cannot create RRule: tzid is required'
);
});
it('throws an error when until field is invalid', () => {
expect(() =>
sanitizeOptions({
...options,
// @ts-expect-error
until: {
getTime: () => NaN,
},
})
).toThrowError('Cannot create RRule: until is an invalid date');
});
it('throws an error when interval is less than 0', () => {
expect(() => sanitizeOptions({ ...options, interval: -3 })).toThrowError(
'Cannot create RRule: interval must be greater than 0'
);
});
it('throws an error when interval is not a number', () => {
// @ts-expect-error
expect(() => sanitizeOptions({ ...options, interval: 'foobar' })).toThrowError(
'Cannot create RRule: interval must be a number'
);
});
it('filters out invalid bymonth values', () => {
expect(sanitizeOptions({ ...options, bymonth: [0, 6, 13] })).toEqual({
...options,
bymonth: [6],
});
});
it('removes bymonth when it is empty', () => {
expect(sanitizeOptions({ ...options, bymonth: [0] })).toEqual({
...options,
bymonth: undefined,
});
});
it('filters out invalid bymonthday values', () => {
expect(sanitizeOptions({ ...options, bymonthday: [0, 15, 32] })).toEqual({
...options,
bymonthday: [15],
});
});
it('removes bymonthday when it is empty', () => {
expect(sanitizeOptions({ ...options, bymonthday: [0] })).toEqual({
...options,
bymonthday: undefined,
});
});
it('filters out invalid byweekday values', () => {
// @ts-expect-error
expect(sanitizeOptions({ ...options, byweekday: [0, 4, 8] })).toEqual({
...options,
byweekday: [4],
});
});
it('removes byweekday when it is empty', () => {
// @ts-expect-error
expect(sanitizeOptions({ ...options, byweekday: [0] })).toEqual({
...options,
byweekday: undefined,
});
});
it('filters out invalid byyearday values', () => {
expect(sanitizeOptions({ ...options, byyearday: [0, 150, 367] })).toEqual({
...options,
byyearday: [150],
});
});
it('removes byyearday when it is empty', () => {
expect(sanitizeOptions({ ...options, byyearday: [0] })).toEqual({
...options,
byyearday: undefined,
});
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Options } from './types';
export function sanitizeOptions(opts: Options) {
const options = { ...opts };
// Guard against invalid options that can't be omitted
if (!options.dtstart) {
throw new Error('Cannot create RRule: dtstart is required');
}
if (!options.tzid) {
throw new Error('Cannot create RRule: tzid is required');
}
if (isNaN(options.dtstart.getTime())) {
throw new Error('Cannot create RRule: dtstart is an invalid date');
}
if (options.until && isNaN(options.until.getTime())) {
throw new Error('Cannot create RRule: until is an invalid date');
}
if (options.interval != null) {
if (typeof options.interval !== 'number') {
throw new Error('Cannot create RRule: interval must be a number');
}
if (options.interval < 1) {
throw new Error('Cannot create RRule: interval must be greater than 0');
}
}
// Omit invalid options
if (options.bymonth) {
// Only months between 1 and 12 are valid
options.bymonth = options.bymonth.filter(
(month) => typeof month === 'number' && month >= 1 && month <= 12
);
if (!options.bymonth.length) {
delete options.bymonth;
}
}
if (options.bymonthday) {
// Only days between 1 and 31 are valid
options.bymonthday = options.bymonthday.filter(
(day) => typeof day === 'number' && day >= 1 && day <= 31
);
if (!options.bymonthday.length) {
delete options.bymonthday;
}
}
if (options.byweekday) {
// Only weekdays between 1 and 7 are valid
options.byweekday = options.byweekday.filter(
(weekday) => typeof weekday === 'number' && weekday >= 1 && weekday <= 7
);
if (!options.byweekday.length) {
delete options.byweekday;
}
}
if (options.byyearday) {
// Only days between 1 and 366 are valid
options.byyearday = options.byyearday.filter((day) => day >= 1 && day <= 366);
if (!options.byyearday.length) {
delete options.byyearday;
}
}
return options;
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { Moment } from 'moment';
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
}
export enum Weekday {
MO = 1,
TU = 2,
WE = 3,
TH = 4,
FR = 5,
SA = 6,
SU = 7,
}
export type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
export interface IterOptions {
refDT: Moment;
wkst?: Weekday | number | null;
byyearday?: number[] | null;
bymonth?: number[] | null;
bysetpos?: number[] | null;
bymonthday?: number[] | null;
byweekday?: Weekday[] | null;
byhour?: number[] | null;
byminute?: number[] | null;
bysecond?: number[] | null;
}
export type Options = Omit<IterOptions, 'refDT'> & {
dtstart: Date;
freq?: Frequency;
interval?: number;
until?: Date | null;
count?: number;
tzid: string;
};

View file

@ -34,17 +34,28 @@ export const rRuleRequestSchema = schema.object({
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRecurrenceByV1('byweekday'),
})
schema.arrayOf(
schema.oneOf([
schema.literal('MO'),
schema.literal('TU'),
schema.literal('WE'),
schema.literal('TH'),
schema.literal('FR'),
schema.literal('SA'),
schema.literal('SU'),
]),
{
validate: createValidateRecurrenceByV1('byweekday'),
}
)
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 31 }), {
validate: createValidateRecurrenceByV1('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 12 }), {
validate: createValidateRecurrenceByV1('bymonth'),
})
),

View file

@ -29,17 +29,28 @@ export const rRuleRequestSchema = schema.object({
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRecurrenceBy('byweekday'),
})
schema.arrayOf(
schema.oneOf([
schema.literal('MO'),
schema.literal('TU'),
schema.literal('WE'),
schema.literal('TH'),
schema.literal('FR'),
schema.literal('SA'),
schema.literal('SU'),
]),
{
validate: createValidateRecurrenceBy('byweekday'),
}
)
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 31 }), {
validate: createValidateRecurrenceBy('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
schema.arrayOf(schema.number({ min: 1, max: 12 }), {
validate: createValidateRecurrenceBy('bymonth'),
})
),

View file

@ -230,4 +230,25 @@ describe('isSnoozeActive', () => {
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
test('snooze still works with invalid bymonth value', () => {
// Set the current time as:
// - Feb 27 2023 08:15:00 GMT+0000 - Monday
fakeTimer = sinon.useFakeTimers(new Date('2023-02-09T08:15:00.000Z'));
const snoozeA = {
duration: moment('2023-01', 'YYYY-MM').daysInMonth() * 24 * 60 * 60 * 1000, // 1 month
rRule: {
freq: Frequency.YEARLY,
interval: 1,
bymonthday: [1],
bymonth: [0],
tzid: 'Europe/Madrid',
dtstart: '2023-01-01T00:00:00.000Z',
} as RRuleRecord,
id: '9141dc1f-ed85-4656-91e4-119173105432',
};
expect(isSnoozeActive(snoozeA)).toMatchInlineSnapshot(`null`);
fakeTimer.restore();
});
});