| 1 | """Password-protected sections both ways: OneNote 2010 cold-opens and unlocks sections |
| 2 | Snowbound protected, edited, gave another password, unprotected and merged, and Snowbound |
| 3 | reads what OneNote wrote, protecting, changing and removing passwords and typing into them |
| 4 | (`corpus/protected-sections`).""" |
| 5 | import json |
| 6 | from pathlib import Path |
| 7 | import re |
| 8 | import subprocess |
| 9 | from tempfile import TemporaryDirectory |
| 10 | import unittest |
| 11 | import xml.etree.ElementTree as ET |
| 12 | |
| 13 | from document_model import EXPORTER |
| 14 | |
| 15 | ROOT = Path(__file__).resolve().parent.parent |
| 16 | FIXTURE = ROOT / 'corpus/protected-sections' |
| 17 | ONE = '{http://schemas.microsoft.com/office/onenote/2010/onenote}' |
| 18 | |
| 19 | |
| 20 | def runs(value): |
| 21 | if isinstance(value, dict): |
| 22 | own = [value['text']] if isinstance(value.get('text'), str) else [] |
| 23 | return own + [text for item in value.values() if isinstance(item, (dict, list)) for text in runs(item)] |
| 24 | if isinstance(value, list): |
| 25 | return [text for item in value for text in runs(item)] |
| 26 | return [] |
| 27 | |
| 28 | |
| 29 | def plain(text): |
| 30 | """A native `T` element's text without its HTML spans.""" |
| 31 | return re.sub(r'<[^>]+>', '', text).replace('\n', ' ').strip() |
| 32 | |
| 33 | |
| 34 | def squeezed(text): |
| 35 | return re.sub(r'\s', '', text) |
| 36 | |
| 37 | |
| 38 | def clear(text, stored): |
| 39 | """Whether `stored` holds `text` in the clear, as OneNote stores text: UTF-16 or ASCII.""" |
| 40 | return text.encode('utf-16-le') in stored or text.encode() in stored |
| 41 | |
| 42 | |
| 43 | class ProtectedSectionsTest(unittest.TestCase): |
| 44 | def setUp(self): |
| 45 | self.temporary = TemporaryDirectory() |
| 46 | self.root = Path(self.temporary.name) |
| 47 | |
| 48 | def tearDown(self): |
| 49 | self.temporary.cleanup() |
| 50 | |
| 51 | def export(self, section, password): |
| 52 | """The text Snowbound reads from `section`, under `password` when it has one.""" |
| 53 | output = self.root / f'export-{len(list(self.root.iterdir()))}' |
| 54 | command = [EXPORTER, section, output] |
| 55 | if password is not None: |
| 56 | file = self.root / f'password-{len(list(self.root.iterdir()))}' |
| 57 | file.write_text(password) |
| 58 | command += ['--password-file', file] |
| 59 | subprocess.run(command, check=True, capture_output=True) |
| 60 | return ''.join(runs(json.loads((output / 'text.json').read_text()))) |
| 61 | |
| 62 | def native_pages(self): |
| 63 | """The cold read's paragraph texts by section name.""" |
| 64 | hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot() |
| 65 | sections, encrypted = {}, set() |
| 66 | for section in hierarchy.iter(ONE + 'Section'): |
| 67 | if section.get('encrypted') == 'true': |
| 68 | encrypted.add(section.get('name')) |
| 69 | for page in section.iter(ONE + 'Page'): |
| 70 | sections[page.get('ID')] = section.get('name') |
| 71 | found = {} |
| 72 | for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')): |
| 73 | page = ET.parse(path).getroot() |
| 74 | name = sections[page.get('ID')] |
| 75 | found.setdefault(name, []).extend( |
| 76 | plain(element.text) for element in page.iter(ONE + 'T') if element.text and plain(element.text)) |
| 77 | return found, encrypted |
| 78 | |
| 79 | def test_onenote_unlocks_and_reads_every_section_snowbound_protected(self): |
| 80 | passwords = json.loads((FIXTURE / 'candidate/passwords.json').read_text()) |
| 81 | found, encrypted = self.native_pages() |
| 82 | self.assertEqual(encrypted, {Path(name).stem for name, password in passwords.items() if password}) |
| 83 | expected = { |
| 84 | 'Edited': ['Edited first under the key. Edited second under the key. Edited third under the key.'], |
| 85 | 'Changed': ['Written under the changed password.'], |
| 86 | 'Conflicted': ['Ours offline.'], |
| 87 | } |
| 88 | for name, password in passwords.items(): |
| 89 | stem = Path(name).stem |
| 90 | native = ' '.join(found[stem]) |
| 91 | self.assertIn('Fictitious positioned outline.', native, stem) |
| 92 | for text in expected.get(stem, []): |
| 93 | self.assertIn(text, native, stem) |
| 94 | stored = (FIXTURE / 'candidate/notebook' / name).read_bytes() |
| 95 | for text in expected.get(stem, []) + ['Fictitious positioned outline.']: |
| 96 | self.assertEqual(clear(text, stored), password is None, stem) |
| 97 | # Snowbound's own read of the candidate holds every paragraph OneNote read but the |
| 98 | # one OneNote typed after. |
| 99 | read = self.export(FIXTURE / 'candidate/notebook' / name, password) |
| 100 | for paragraph in found[stem]: |
| 101 | if paragraph != 'Typed by OneNote 2010 under the key.': |
| 102 | self.assertIn(squeezed(paragraph), squeezed(read), stem) |
| 103 | |
| 104 | def test_snowbound_reads_what_onenote_wrote_into_its_protected_sections(self): |
| 105 | passwords = json.loads((FIXTURE / 'candidate/passwords.json').read_text()) |
| 106 | for name, password in passwords.items(): |
| 107 | read = self.export(FIXTURE / 'cold/notebook' / name, password) |
| 108 | self.assertIn('Fictitiouspositionedoutline.', squeezed(read), name) |
| 109 | if name == 'Edited.one': |
| 110 | self.assertIn('TypedbyOneNote2010underthekey.', squeezed(read)) |
| 111 | self.assertIn('Editedthirdunderthekey.', squeezed(read)) |
| 112 | wrong = subprocess.run([EXPORTER, FIXTURE / 'cold/notebook/Edited.one', '--password-file', |
| 113 | self.write('Protected gate password')], capture_output=True) |
| 114 | self.assertNotEqual(wrong.returncode, 0) |
| 115 | |
| 116 | def write(self, text): |
| 117 | file = self.root / 'wrong' |
| 118 | file.write_text(text) |
| 119 | return file |
| 120 | |
| 121 | def test_onenote_protected_changed_and_removed_sections_read_back(self): |
| 122 | manifest = json.loads((FIXTURE / 'manifest.json').read_text()) |
| 123 | for name, password in manifest['native'].items(): |
| 124 | read = self.export(FIXTURE / 'native' / name, password) |
| 125 | self.assertIn('Secretwords:secretwordhidden', squeezed(read), name) |
| 126 | stored = (FIXTURE / 'native' / name).read_bytes() |
| 127 | self.assertEqual(clear('secretword', stored), password is None, name) |
| 128 | |
| 129 | |
| 130 | if __name__ == '__main__': |
| 131 | unittest.main() |