1"""Password-protected sections both ways: OneNote 2010 cold-opens and unlocks sections
2Snowbound protected, edited, gave another password, unprotected and merged, and Snowbound
3reads what OneNote wrote, protecting, changing and removing passwords and typing into them
4(`corpus/protected-sections`)."""
5import json
6from pathlib import Path
7import re
8import subprocess
9from tempfile import TemporaryDirectory
10import unittest
11import xml.etree.ElementTree as ET
12
13from document_model import EXPORTER
14
15ROOT = Path(__file__).resolve().parent.parent
16FIXTURE = ROOT / 'corpus/protected-sections'
17ONE = '{http://schemas.microsoft.com/office/onenote/2010/onenote}'
18
19
20def 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
29def plain(text):
30 """A native `T` element's text without its HTML spans."""
31 return re.sub(r'<[^>]+>', '', text).replace('\n', ' ').strip()
32
33
34def squeezed(text):
35 return re.sub(r'\s', '', text)
36
37
38def 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
43class 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
130if __name__ == '__main__':
131 unittest.main()