| 1 | use onestore::{ExGuid, FileDataReference, ObjectData, PropertySets, RevisionIndex, Store, Value}; |
| 2 | use std::fs; |
| 3 | |
| 4 | const TABLE: &str = "../../corpus/native/20260905-05/snapshots/07-table/notebook/synthetic.one"; |
| 5 | |
| 6 | #[test] |
| 7 | fn persisted_identities_preserve_native_byte_order_and_canonical_form() { |
| 8 | let text = "{00112233-4455-6677-8899-AABBCCDDEEFF},42"; |
| 9 | let id: ExGuid = text.parse().unwrap(); |
| 10 | assert_eq!( |
| 11 | id.guid, |
| 12 | [ |
| 13 | 0x33, 0x22, 0x11, 0, 0x55, 0x44, 0x77, 0x66, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, |
| 14 | 0xff |
| 15 | ] |
| 16 | ); |
| 17 | assert_eq!(id.n, 42); |
| 18 | assert_eq!(text.to_lowercase().parse::<ExGuid>().unwrap(), id); |
| 19 | assert_eq!(id.to_string(), text); |
| 20 | let mut random = 7_u64; |
| 21 | for n in 0..1024 { |
| 22 | let guid = std::array::from_fn(|_| { |
| 23 | random = random.wrapping_mul(6364136223846793005).wrapping_add(1); |
| 24 | random.to_le_bytes()[0] |
| 25 | }); |
| 26 | let id = ExGuid { guid, n }; |
| 27 | assert_eq!(id.to_string().parse::<ExGuid>().unwrap(), id); |
| 28 | assert_eq!( |
| 29 | serde_json::from_str::<ExGuid>(&serde_json::to_string(&id).unwrap()).unwrap(), |
| 30 | id |
| 31 | ); |
| 32 | } |
| 33 | for id in [ |
| 34 | ExGuid::default(), |
| 35 | ExGuid { |
| 36 | guid: [255; 16], |
| 37 | n: u32::MAX, |
| 38 | }, |
| 39 | ] { |
| 40 | assert_eq!(id.to_string().parse::<ExGuid>().unwrap(), id); |
| 41 | } |
| 42 | for at in 0..text.len() { |
| 43 | let mut changed = text.as_bytes().to_vec(); |
| 44 | changed[at] = b'?'; |
| 45 | assert!( |
| 46 | String::from_utf8(changed) |
| 47 | .unwrap() |
| 48 | .parse::<ExGuid>() |
| 49 | .is_err() |
| 50 | ); |
| 51 | } |
| 52 | for changed in [ |
| 53 | text.replace(",42", ",+42"), |
| 54 | text.replace(",42", ",042"), |
| 55 | text.replace(",42", ",4294967296"), |
| 56 | text.replace(",42", ",-1"), |
| 57 | text.replace("00", "+0"), |
| 58 | text.replace("00", "é"), |
| 59 | "{00000000-0000-0000-0000-000000000000},1".to_owned(), |
| 60 | format!(" {text}"), |
| 61 | format!("{text} "), |
| 62 | ] { |
| 63 | assert!(changed.parse::<ExGuid>().is_err(), "{changed}"); |
| 64 | assert!(serde_json::from_str::<ExGuid>(&serde_json::to_string(&changed).unwrap()).is_err()); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | #[test] |
| 69 | fn native_encryption_remains_opaque() { |
| 70 | for path in [ |
| 71 | "../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one", |
| 72 | "../../corpus/native-protected-boundaries/notebook/synthetic.one", |
| 73 | ] { |
| 74 | let bytes = fs::read(path).unwrap(); |
| 75 | let store = Store::parse(&bytes).unwrap(); |
| 76 | assert!(store.checksum_mismatches.is_empty()); |
| 77 | let index = RevisionIndex::parse(&store).unwrap(); |
| 78 | let mut encrypted = 0; |
| 79 | for (osid, space) in &index.spaces { |
| 80 | for (rid, revision) in &space.revisions { |
| 81 | let resolved = index.resolve(*osid, *rid).unwrap(); |
| 82 | if revision.encrypted { |
| 83 | encrypted += 1; |
| 84 | assert!( |
| 85 | resolved |
| 86 | .objects |
| 87 | .values() |
| 88 | .any(|object| matches!(object.data, ObjectData::Encrypted(_))) |
| 89 | ); |
| 90 | assert_eq!( |
| 91 | resolved.reachable().unwrap_err().message, |
| 92 | "Encrypted property references are unavailable" |
| 93 | ); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | assert!(encrypted > 0); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | #[test] |
| 102 | fn external_payload_names_cannot_escape_the_onefiles_directory() { |
| 103 | for (reference, valid) in [ |
| 104 | ("<file>2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin", true), |
| 105 | ( |
| 106 | "<file>../2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin", |
| 107 | false, |
| 108 | ), |
| 109 | ( |
| 110 | "<file>C:\\2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin", |
| 111 | false, |
| 112 | ), |
| 113 | ( |
| 114 | "<file>2a83ae62-6754-4383-8e2b-4033ff3cfba1.onebin:stream", |
| 115 | false, |
| 116 | ), |
| 117 | ("<ifndf>{2a83ae62-6754-4383-8e2b-4033ff3cfba1}", true), |
| 118 | ("<invfdo>", true), |
| 119 | ("<invfdo>garbage", false), |
| 120 | ] { |
| 121 | let bytes: Vec<_> = reference |
| 122 | .encode_utf16() |
| 123 | .flat_map(u16::to_le_bytes) |
| 124 | .collect(); |
| 125 | let object = onestore::Object { |
| 126 | jcid: 0x80036, |
| 127 | reference_count: 1, |
| 128 | data: ObjectData::File { |
| 129 | reference: &bytes, |
| 130 | extension: &[], |
| 131 | }, |
| 132 | global_ids: Default::default(), |
| 133 | }; |
| 134 | assert_eq!(object.file_reference().is_ok(), valid, "{reference}"); |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | #[test] |
| 139 | fn active_revision_contains_current_text_and_exact_attachment_bytes() { |
| 140 | let bytes = fs::read(TABLE).unwrap(); |
| 141 | let store = Store::parse(&bytes).unwrap(); |
| 142 | let index = RevisionIndex::parse(&store).unwrap(); |
| 143 | let mut strings = Vec::new(); |
| 144 | let mut attachments = Vec::new(); |
| 145 | let mut pages = 0; |
| 146 | for (osid, space) in &index.spaces { |
| 147 | let rid = space.labels[&(ExGuid::default(), 1)]; |
| 148 | let revision = index.resolve(*osid, rid).unwrap(); |
| 149 | for oid in revision.reachable().unwrap() { |
| 150 | let object = &revision.objects[&oid]; |
| 151 | pages += usize::from(object.jcid == 0x6000b); |
| 152 | if let Some(FileDataReference::Internal(guid)) = object.file_reference().unwrap() |
| 153 | && object.jcid == 0x80036 |
| 154 | { |
| 155 | attachments.push(store.file_data(guid).unwrap()); |
| 156 | } |
| 157 | if object.jcid != 0x6000e { |
| 158 | continue; |
| 159 | } |
| 160 | let ObjectData::Properties(data) = object.data else { |
| 161 | panic!() |
| 162 | }; |
| 163 | let props = PropertySets::parse(data).unwrap(); |
| 164 | let unicode = props.sets[0] |
| 165 | .iter() |
| 166 | .find(|property| property.id == 0x1c001c22); |
| 167 | if let Some(property) = unicode { |
| 168 | let Value::Bytes(data) = property.value else { |
| 169 | panic!() |
| 170 | }; |
| 171 | let text = String::from_utf16( |
| 172 | &data |
| 173 | .chunks_exact(2) |
| 174 | .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) |
| 175 | .collect::<Vec<_>>(), |
| 176 | ) |
| 177 | .unwrap(); |
| 178 | strings.push(text.strip_suffix('\0').unwrap().to_owned()); |
| 179 | } else if let Some(property) = props.sets[0] |
| 180 | .iter() |
| 181 | .find(|property| property.id == 0x1c003498) |
| 182 | { |
| 183 | let Value::Bytes(data) = property.value else { |
| 184 | panic!() |
| 185 | }; |
| 186 | strings.push(String::from_utf8(data.to_vec()).unwrap()); |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | strings.sort(); |
| 191 | assert_eq!( |
| 192 | strings, |
| 193 | [ |
| 194 | "Fictitious positioned outline.", |
| 195 | "Fictitious: café, 東京, مرحبا", |
| 196 | "Left cell", |
| 197 | "Right cell" |
| 198 | ] |
| 199 | ); |
| 200 | assert_eq!(pages, 1); |
| 201 | assert_eq!( |
| 202 | attachments, |
| 203 | [fs::read("../../corpus/native/20260905-05/assets/fictitious-attachment.txt").unwrap()] |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn self_dependent_revision_is_rejected() { |
| 209 | let mut bytes = fs::read(TABLE).unwrap(); |
| 210 | let store = Store::parse(&bytes).unwrap(); |
| 211 | let node = store |
| 212 | .lists |
| 213 | .values() |
| 214 | .flat_map(|list| &list.nodes) |
| 215 | .find(|node| node.id == 0x1e) |
| 216 | .unwrap(); |
| 217 | let start = node.payload.as_ptr().addr() - bytes.as_ptr().addr(); |
| 218 | bytes.copy_within(start..start + 20, start + 20); |
| 219 | let store = Store::parse(&bytes).unwrap(); |
| 220 | assert_eq!( |
| 221 | RevisionIndex::parse(&store).unwrap_err().message, |
| 222 | "Revision dependency is not an earlier revision" |
| 223 | ); |
| 224 | } |
| 225 | |
| 226 | #[test] |
| 227 | fn damaged_read_only_object_is_rejected_by_its_hash() { |
| 228 | let mut bytes = fs::read(TABLE).unwrap(); |
| 229 | let store = Store::parse(&bytes).unwrap(); |
| 230 | let node = store |
| 231 | .lists |
| 232 | .values() |
| 233 | .flat_map(|list| &list.nodes) |
| 234 | .find(|node| node.id == 0xc4) |
| 235 | .unwrap(); |
| 236 | let onestore::Reference::Data(chunk) = node.reference.unwrap() else { |
| 237 | panic!() |
| 238 | }; |
| 239 | bytes[usize::try_from(chunk.offset).unwrap()] ^= 1; |
| 240 | let store = Store::parse(&bytes).unwrap(); |
| 241 | let index = RevisionIndex::parse(&store).unwrap(); |
| 242 | assert!( |
| 243 | index |
| 244 | .spaces |
| 245 | .iter() |
| 246 | .any(|(osid, space)| space.revisions.keys().any(|rid| { |
| 247 | index |
| 248 | .resolve(*osid, *rid) |
| 249 | .is_err_and(|error| error.message == "Read-only object checksum mismatch") |
| 250 | })) |
| 251 | ); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn reference_counts_and_graph_cycles_are_checked_independently_of_chunk_hashes() { |
| 256 | let bytes = fs::read(TABLE).unwrap(); |
| 257 | let store = Store::parse(&bytes).unwrap(); |
| 258 | let index = RevisionIndex::parse(&store).unwrap(); |
| 259 | let space = &index.spaces[&index.root]; |
| 260 | let rid = space.labels[&(ExGuid::default(), 1)]; |
| 261 | let mut revision = index.resolve(index.root, rid).unwrap(); |
| 262 | revision.reachable().unwrap(); |
| 263 | let root = revision.roots[&1]; |
| 264 | revision.objects.get_mut(&root).unwrap().reference_count += 1; |
| 265 | assert_eq!( |
| 266 | revision.reachable().unwrap_err().message, |
| 267 | "Stored object reference count disagrees with the reachable graph" |
| 268 | ); |
| 269 | let mut data = 0x80000001_u32.to_le_bytes().to_vec(); |
| 270 | data.extend_from_slice(&(0x100 | root.n).to_le_bytes()); |
| 271 | data.extend_from_slice(&1_u16.to_le_bytes()); |
| 272 | data.extend_from_slice(&0x20000001_u32.to_le_bytes()); |
| 273 | let object = revision.objects.get_mut(&root).unwrap(); |
| 274 | object.data = ObjectData::Properties(&data); |
| 275 | object.global_ids = std::sync::Arc::new([(1, root.guid)].into()); |
| 276 | assert_eq!( |
| 277 | revision.reachable().unwrap_err().message, |
| 278 | "Object references form a cycle" |
| 279 | ); |
| 280 | } |