| 1 | #!/usr/bin/env python3 |
| 2 | """Verify captured SMB collaboration and recovery against native cold reads.""" |
| 3 | from collections import defaultdict |
| 4 | import hashlib |
| 5 | import json |
| 6 | from pathlib import Path |
| 7 | import subprocess |
| 8 | import xml.etree.ElementTree as ET |
| 9 | from native_xml import ns, pages, texts |
| 10 | |
| 11 | root = Path(__file__).resolve().parent.parent |
| 12 | corpus = root / 'corpus/collaboration/round-01' |
| 13 | for item in json.loads((corpus / 'manifest.json').read_text())['files']: |
| 14 | data = (corpus / item['path']).read_bytes() |
| 15 | assert len(data) == item['bytes'] |
| 16 | assert hashlib.sha256(data).hexdigest() == item['sha256'], item['path'] |
| 17 | |
| 18 | expected = { |
| 19 | 'fault-01': ['A notebook created in Rust.'], |
| 20 | 'fault-02': ['Publication survived a lost reply.'], |
| 21 | 'fault-03': ['Cleanup survived a lost reply.'], |
| 22 | 'live-01': ['Live Rust: café, 東京, مرحبا', 'Rust same paragraph.', 'Left cell', 'Right cell'], |
| 23 | 'offline-02': ['Rust edited during the native outage.'], |
| 24 | 'contention-01': ['Native edit waited for the lock.'], |
| 25 | } |
| 26 | for name, text in expected.items(): |
| 27 | captured = pages(corpus / 'native' / f'stage5-cold-{name}' / 'read') |
| 28 | assert len(captured) == 1 and texts(captured[0]) == text, name |
| 29 | |
| 30 | subprocess.run(['cargo', 'build', '-p', 'onestore', '--quiet', '--example', 'inventory'], cwd=root, check=True) |
| 31 | for case, main, conflict in [ |
| 32 | ('live', 'Rust same paragraph.', 'Native same paragraph.'), |
| 33 | ('offline', 'Rust edited during the native outage.', 'Native edited while disconnected.'), |
| 34 | ]: |
| 35 | output = subprocess.check_output([root / 'target/debug/examples/inventory', corpus / case / 'notebook/synthetic.one'], text=True) |
| 36 | by_space = defaultdict(list) |
| 37 | metadata = {} |
| 38 | for row in output.splitlines(): |
| 39 | kind, space, value = row.split('\t') |
| 40 | if kind == 'page': |
| 41 | metadata[space] = int(value, 16) |
| 42 | elif kind in ('ascii', 'unicode'): |
| 43 | by_space[space].append(bytes.fromhex(value).decode('ascii' if kind == 'ascii' else 'utf-16-le').rstrip('\0')) |
| 44 | assert sum(metadata.get(space) == 0x20030 and main in text for space, text in by_space.items()) == 1 |
| 45 | assert any(metadata.get(space) == 0x20038 and conflict in text for space, text in by_space.items()) |
| 46 | |
| 47 | before = ET.parse(corpus / 'live/before-native-edit.xml').getroot() |
| 48 | merged = ET.parse(corpus / 'live/merged.xml').getroot() |
| 49 | assert 'Fictitious: café, 東京, مرحبا' in texts(before) |
| 50 | assert texts(merged) == ['Live Rust: café, 東京, مرحبا', 'Native edited the other outline.', 'Left cell', 'Right cell'] |
| 51 | |
| 52 | # Cache identities and edit timestamps change across native COM saves. |
| 53 | def content(node): |
| 54 | attrs = {key: value for key, value in node.attrib.items() |
| 55 | if key not in ('objectID', 'pathCache', 'creationTime', 'lastModifiedTime')} |
| 56 | return node.tag, sorted(attrs.items()), (node.text or '').strip(), [content(child) for child in node] |
| 57 | |
| 58 | original = pages(root / 'corpus/native-ink/cold-ui-ink/read')[0] |
| 59 | final = pages(corpus / 'native/stage5-cold-live-01/read')[0] |
| 60 | # These exact geometry changes appeared in the first native COM merge capture. |
| 61 | ink_position = original.findall('one:InkDrawing/one:Position', ns)[1] |
| 62 | assert ink_position.get('z') == '7' |
| 63 | assert merged.findall('one:InkDrawing/one:Position', ns)[1].get('z') == '6' |
| 64 | ink_position.set('z', '6') |
| 65 | column = original.find('.//one:Table/one:Columns/one:Column', ns) |
| 66 | assert column.get('width') == '39.14614105224609' |
| 67 | assert merged.find('.//one:Table/one:Columns/one:Column', ns).get('width') == '38.61000061035156' |
| 68 | column.set('width', '38.61000061035156') |
| 69 | for tag in ('Image', 'InkDrawing', 'Table', 'InsertedFile'): |
| 70 | old = [content(node) for node in original.findall(f'.//one:{tag}', ns)] |
| 71 | new = [content(node) for node in final.findall(f'.//one:{tag}', ns)] |
| 72 | assert old and old == new, tag |
| 73 | assert [content(node) for node in original.findall('one:QuickStyleDef', ns)] == [content(node) for node in final.findall('one:QuickStyleDef', ns)] |
| 74 | assert [node.attrib for node in original.findall('one:Outline/one:Position', ns)] == [node.attrib for node in final.findall('one:Outline/one:Position', ns)] |
| 75 | assert next((root / 'corpus/native-ink/cold-ui-ink/read').glob('*.attachment')).read_bytes() == next((corpus / 'native/stage5-cold-live-01/read').glob('*.attachment')).read_bytes() |
| 76 | |
| 77 | lock = json.loads((corpus / 'contention/lock-result.json').read_text()) |
| 78 | assert lock['changed_while_locked'] is False |
| 79 | assert lock['before_sha256'] == lock['after_sha256'] |
| 80 | assert hashlib.sha256((corpus / 'contention/notebook/synthetic.one').read_bytes()).hexdigest() != lock['after_sha256'] |
| 81 | assert 'write' in (corpus / 'contention/locks.txt').read_text() |
| 82 | assert json.loads((corpus / 'contention/controller/inbox/0002.json').read_text())['text'] == expected['contention-01'][0] |
| 83 | for action in (2, 3): |
| 84 | assert (corpus / f'contention/controller/outbox/{action}/done').read_text() == 'ok' |
| 85 | |
| 86 | trace = [json.loads(line) for line in (corpus / 'smb-faults.jsonl').read_text().splitlines()] |
| 87 | cuts = [i for i, row in enumerate(trace) if 'cut' in row] |
| 88 | assert len(cuts) == 3 |
| 89 | start = 0 |
| 90 | for end, counter, state, case in zip(cuts, (1, 2, 256), ('NotCommitted', 'Unknown', 'Committed'), ('fault-01', 'fault-02', 'fault-03')): |
| 91 | cut = trace[end]['cut'] |
| 92 | assert cut['command'] == 7 and cut['direction'] == 'response' and cut['status'] == '0x0' |
| 93 | segment = trace[start:end] |
| 94 | assert [row['transactions'] for row in segment if 'transactions' in row][-1] == counter |
| 95 | assert any([0, 2**64 - 1, 18] in row.get('locks', []) for row in segment) |
| 96 | result = json.loads((corpus / case / 'result.json').read_text()) |
| 97 | assert result['exit'] != 0 |
| 98 | assert result.get('state') == state or f'state: {state},' in result.get('stderr', '') |
| 99 | if counter == 256: |
| 100 | publication = next(i for i, row in enumerate(segment) if row.get('transactions') == 511) |
| 101 | cleanup = next(i for i, row in enumerate(segment) if row.get('transactions') == 256) |
| 102 | assert any(row.get('direction') == 'response' and row.get('command') == 7 and row.get('status') == '0x0' for row in segment[publication + 1:cleanup]) |
| 103 | start = end + 1 |
| 104 | print('Passed: native lock contention, disjoint merge, retained conflict edits, offline convergence, three lost-FLUSH outcomes, and cold native content preservation') |