diff --git a/omega-pac/src/rule_list.coffee b/omega-pac/src/rule_list.coffee index d519d72..6f3988f 100644 --- a/omega-pac/src/rule_list.coffee +++ b/omega-pac/src/rule_list.coffee @@ -52,7 +52,51 @@ module.exports = exports = list.push({condition: cond, profileName: profile, source: source}) # Exclusive rules have higher priority, so they come first. return exclusive_rules.concat normal_rules + 'Switchy': + omegaPrefix: '[SwitchyOmega Conditions' + detect: (text) -> + text = text.trim() + if strStartsWith(text, exports['Switchy'].omegaPrefix) + return true + return + + parse: (text, matchProfileName, defaultProfileName) -> + text = text.trim() + switchy = exports['Switchy'] + parser = 'parseOmega' + if not strStartsWith(text, switchy.omegaPrefix) + if text[0] == '#' or text.indexOf('\n#') >= 0 + parser = 'parseLegacy' + return switchy[parser](text, matchProfileName, defaultProfileName) + + # For the omega rule list format, please see the following wiki page: + # https://github.com/FelisCatus/SwitchyOmega/wiki/SwitchyOmega-conditions-format + compose: ({rules, defaultProfileName}, {withResult, useExclusive} = {}) -> + eol = '\r\n' + ruleList = '[SwitchyOmega Conditions]' + eol + useExclusive ?= not withResult + if withResult + ruleList += '@with result' + eol + eol + else + ruleList += eol + for rule in rules + line = Conditions.str(rule.condition) + if line[0] == '#' or line[0] == '+' + # Escape leading # to avoid being detected as legacy format. + # Reserve leading + for condition results. + line = ': ' + line + if useExclusive and rule.profileName == defaultProfileName + line = '!' + line + else if withResult + # TODO(catus): What if rule.profileName contains ' +' or new lines? + line += ' +' + rule.profileName + ruleList += line + eol + if withResult + # TODO(catus): Also special chars and sequences in defaultProfileName. + ruleList += '* +' + defaultProfileName + eol + return ruleList + conditionFromLegacyWildcard: (pattern) -> if pattern[0] == '@' pattern = pattern.substring(1) @@ -70,8 +114,7 @@ module.exports = exports = conditionType: 'UrlWildcardCondition' pattern: pattern - parse: (text, matchProfileName, defaultProfileName) -> - text = text.trim() + parseLegacy: (text, matchProfileName, defaultProfileName) -> normal_rules = [] exclusive_rules = [] begin = false @@ -107,3 +150,58 @@ module.exports = exports = list.push({condition: cond, profileName: profile, source: source}) # Exclusive rules have higher priority, so they come first. return exclusive_rules.concat normal_rules + + parseOmega: (text, matchProfileName, defaultProfileName) -> + rules = [] + rulesWithDefaultProfile = [] + withResult = false + for line in text.split(/\n|\r/) + line = line.trim() + continue if line.length == 0 + switch line[0] + when '[' # Header line: Ignore. + continue + when ';' # Comment line: Ignore. + continue + when '@' # Directive line: + iSpace = line.indexOf(' ') + iSpace = line.length if iSpace < 0 + directive = line.substr(1, iSpace - 1) + line = line.substr(iSpace + 1).trim() + switch directive.toUpperCase() + when 'WITH' + feature = line.toUpperCase() + if feature == 'RESULT' or feature == 'RESULTS' + withResult = true + continue + + source = null + if line[0] == '!' + profile = if withResult then null else defaultProfileName + source = line + line = line.substr(1) + else if withResult + iSpace = line.lastIndexOf(' +') + if iSpace < 0 + throw new Error("Missing result profile name: " + line) + profile = line.substr(iSpace + 2).trim() + line = line.substr(0, iSpace).trim() + defaultProfileName = profile if line == '*' + else + profile = matchProfileName + + cond = Conditions.fromStr(line) + if not cond + throw new Error("Invalid rule: " + line) + + rule = {condition: cond, profileName: profile, source: source ? line} + rules.push(rule) + if not profile + rulesWithDefaultProfile.push(rule) + + if withResult + if not defaultProfileName + throw new Error("Missing default rule with catch-all '*' condition!") + for rule in rulesWithDefaultProfile + rule.profileName = defaultProfileName + return rules diff --git a/omega-pac/test/rule_list.coffee b/omega-pac/test/rule_list.coffee index 22c6909..30875b5 100644 --- a/omega-pac/test/rule_list.coffee +++ b/omega-pac/test/rule_list.coffee @@ -234,3 +234,163 @@ describe 'RuleList', -> conditionType: 'UrlWildcardCondition' pattern: 'http://www.example.com/*' ) + + describe 'Switchy (omega format)', -> + parse = RuleList['Switchy'].parse + compose = RuleList['Switchy'].compose + it 'should parse empty rule lists', -> + list = compose {rules: []} + result = parse(list, 'match', 'notmatch') + result.should.have.length(0) + it 'should ignore comment lines.', -> + list = compose {rules: []} + list += ';*.example.com \r\n' + result = parse(list, 'match', 'notmatch') + result.should.have.length(0) + it 'should compose and parse HostWildcardCondition', -> + rule = + source: '*.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*.example.com' + profileName: 'match' + list = compose({rules: [rule], defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.have.length(1) + result[0].should.eql(rule) + it 'should compose and parse HostRegexCondition', -> + rule = + source: 'HostRegex: ^http://www\.example\.com/.*' + condition: + conditionType: 'HostRegexCondition', + pattern: '^http://www\.example\.com/.*' + profileName: 'match' + list = compose({rules: [rule], defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.have.length(1) + result[0].should.eql(rule) + it 'should compose and parse disabled rules', -> + rule = + source: 'Disabled: *.example.com' + condition: + conditionType: 'FalseCondition', + pattern: '*.example.com' + profileName: 'match' + list = compose({rules: [rule], defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.have.length(1) + result[0].should.eql(rule) + it 'should compose and parse exclusive rules', -> + rule = + source: '!*.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*.example.com' + profileName: 'notmatch' + list = compose({rules: [rule], defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.have.length(1) + result[0].should.eql(rule) + it 'should parse multiple conditions', -> + rules = [{ + source: '*.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*.example.com' + profileName: 'match' + }, { + source: '*.example.org' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*.example.org' + profileName: 'match' + }] + list = compose({rules: rules, defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.eql(rules) + it 'should respect the top-down order of conditions', -> + rules = [{ + source: 'b.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'b.example.com' + profileName: 'match' + }, { + source: '!a.example.org' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'a.example.org' + profileName: 'notmatch' + }] + list = compose({rules: rules, defaultProfileName: 'notmatch'}) + result = parse(list, 'match', 'notmatch') + result.should.eql(rules) + it 'should add a default rule when results are enabled', -> + list = compose( + {rules: [], defaultProfileName: 'notmatch'} + {withResult: true} + ) + list.split(/\r|\n/).should.contain('@with result') + result = parse(list, 'ignored', 'alsoIgnored') + result.should.have.length(1) + result[0].should.eql({ + source: '*' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*' + profileName: 'notmatch', + }) + it 'should compose and parse conditions with results', -> + rules = [{ + source: 'b.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'b.example.com' + profileName: 'abc' + }, { + source: 'a.example.org' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'a.example.org' + profileName: 'def' + }] + list = compose( + {rules: rules, defaultProfileName: 'ghi'} + {withResult: true} + ) + result = parse(list, 'ignored', 'alsoIgnored') + rules.push({ + source: '*' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*' + profileName: 'ghi', + }) + result.should.eql(rules) + it 'should compose and parse exclusive conditions with results', -> + rules = [{ + source: '!b.example.com' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'b.example.com' + profileName: 'default profile' + }, { + source: 'a.example.org' + condition: + conditionType: 'HostWildcardCondition', + pattern: 'a.example.org' + profileName: 'some profile' + }] + list = compose( + {rules: rules, defaultProfileName: 'default profile'} + {withResult: true, useExclusive: true} + ) + result = parse(list, 'ignored', 'alsoIgnored') + rules.push({ + source: '*' + condition: + conditionType: 'HostWildcardCondition', + pattern: '*' + profileName: 'default profile', + }) + result.should.eql(rules)