1use super::*;
2use onestore::{
3 ExGuid, RevisionIndex, Store, Transaction,
4 document::{Document, Kind},
5 op::{Edit, Op, PageOp},
6};
7use std::{
8 fs,
9 time::{Instant, SystemTime, UNIX_EPOCH},
10};
11
12mod faults;
13
14#[test]
15#[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"]
16fn live_directory() {
17 let client = client();
18 let bytes = fs::read(std::env::var("ONESTORE_SMB_DIRECTORY_ORACLE").unwrap()).unwrap();
19 let oracle: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
20 let root = oracle["path"].as_str().unwrap();
21 let expected = oracle["entries"].as_array().unwrap();
22 let entries = client.read_dir(root, expected.len()).unwrap();
23 assert_eq!(entries.len(), expected.len());
24 for expected in expected {
25 let entry = entries
26 .iter()
27 .find(|entry| entry.name == expected["name"])
28 .unwrap();
29 let directory = expected["directory"].as_bool().unwrap();
30 assert_eq!(entry.attributes & 0x10 != 0, directory, "{}", entry.name);
31 if !directory {
32 assert_eq!(
33 entry.size,
34 expected["size"].as_u64().unwrap(),
35 "{}",
36 entry.name
37 );
38 }
39 }
40 assert_eq!(
41 client.read_dir(root, entries.len() - 1).unwrap_err().kind(),
42 io::ErrorKind::FileTooLarge
43 );
44 assert_eq!(client.read_dir(root, entries.len()).unwrap(), entries);
45 assert!(
46 client
47 .read_dir(&format!("{root}/empty"), 0)
48 .unwrap()
49 .is_empty()
50 );
51 assert_eq!(
52 client
53 .read_dir(&format!("{root}/missing"), 1)
54 .unwrap_err()
55 .kind(),
56 io::ErrorKind::NotFound
57 );
58 assert_eq!(
59 client
60 .read_dir(&format!("{root}/file.one"), 1)
61 .unwrap_err()
62 .kind(),
63 io::ErrorKind::NotADirectory
64 );
65 assert_eq!(
66 client
67 .read_dir(&format!("{root}/denied"), 1)
68 .unwrap_err()
69 .kind(),
70 io::ErrorKind::PermissionDenied
71 );
72 assert!(
73 client
74 .read_dir("", 10_000)
75 .unwrap()
76 .iter()
77 .any(|entry| entry.name == root)
78 );
79}
80
81#[test]
82#[ignore = "requires an owned Samba directory through a proxy that interrupts a later directory page or close"]
83fn live_directory_interruption() {
84 let client = client();
85 let root = std::env::var("ONESTORE_SMB_DIRECTORY").unwrap();
86 let started = Instant::now();
87 assert!(client.read_dir(&root, 10_000).is_err());
88 assert!(started.elapsed() < Duration::from_secs(10));
89 assert_eq!(
90 client.read_dir(&root, 10_000).unwrap_err().kind(),
91 io::ErrorKind::NotConnected
92 );
93}
94
95fn client() -> Client {
96 Client::connect(
97 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
98 "agent",
99 Credentials::default(),
100 Duration::from_secs(5),
101 )
102 .unwrap()
103}
104fn create(client: &Client, path: &str, bytes: &[u8]) {
105 let response: CreateResponse = client
106 .request(
107 Command::Create,
108 CreateRequest {
109 requested_oplock_level: OplockLevel::None,
110 impersonation_level: ImpersonationLevel::Impersonation,
111 desired_access: FileAccessMask::new(0xc0000000),
112 file_attributes: 0,
113 share_access: ShareAccess(7),
114 create_disposition: CreateDisposition::FileCreate,
115 create_options: 0x42,
116 name: path.to_owned(),
117 create_contexts: Vec::new(),
118 },
119 )
120 .unwrap();
121 let mut file = File::new(client, &response);
122 let mut offset = 0;
123 while offset < bytes.len() {
124 offset += file.write_at(offset as u64, &bytes[offset..]).unwrap();
125 }
126 file.flush().unwrap();
127 file.close().unwrap();
128}
129/// The transaction replacing `range` of the first text in `image` with `with`, as typed now.
130fn replaced(image: &[u8], range: std::ops::Range<u32>, with: &str) -> Transaction {
131 let (space, text, _) = text(image);
132 let arena = onestore::Arena::default();
133 let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
134 let at = SystemTime::now()
135 .duration_since(UNIX_EPOCH)
136 .unwrap()
137 .as_nanos() as u64
138 / 100
139 + 116_444_736_000_000_000;
140 section
141 .apply(
142 "Fixture",
143 &Edit {
144 at,
145 ops: vec![Op::Page {
146 space,
147 op: PageOp::Text {
148 text,
149 range,
150 with: with.to_owned(),
151 },
152 }],
153 },
154 )
155 .unwrap();
156 section.seal().unwrap().unwrap()
157}
158
159/// `image` after `transaction`.
160fn applied(image: &[u8], transaction: &Transaction) -> Vec<u8> {
161 let mut image = image.to_vec();
162 transaction.apply(&mut image).unwrap();
163 image
164}
165
166fn text(bytes: &[u8]) -> (ExGuid, ExGuid, String) {
167 let store = Store::parse(bytes).unwrap();
168 assert!(store.checksum_mismatches.is_empty());
169 let index = RevisionIndex::parse(&store).unwrap();
170 index.validate_current().unwrap();
171 let doc = Document::parse(&index).unwrap();
172 doc.spaces
173 .iter()
174 .find_map(|(sid, space)| {
175 let revision = space.active().unwrap();
176 revision
177 .nodes
178 .iter()
179 .find_map(|(oid, node)| match &node.kind {
180 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
181 _ => None,
182 })
183 })
184 .unwrap()
185}
186#[test]
187#[ignore = "requires disposable Samba and an existing ONESTORE_SMB_EVIDENCE directory"]
188fn live_coordination() {
189 let output = std::env::var("ONESTORE_SMB_EVIDENCE").unwrap();
190 let writer = client();
191 let path = format!(
192 "adapter-{}.one",
193 SystemTime::now()
194 .duration_since(UNIX_EPOCH)
195 .unwrap()
196 .as_nanos()
197 );
198 let source = onestore::create_section(&path, "Before café 🦀", "Fixture").unwrap();
199 create(&writer, &path, &source);
200 assert_eq!(writer.read(&path, 1 << 20).unwrap(), source);
201 let readers: Vec<_> = (0..12).map(|_| client()).collect();
202 let mut held: Vec<_> = readers
203 .iter()
204 .map(|client| {
205 client
206 .open(&path, false)
207 .unwrap()
208 .coordinate(&path, false, &[])
209 .unwrap()
210 })
211 .collect();
212 let (_, _, before) = text(&source);
213 let replacement = "After café 🦀";
214 let everything = 0..before.encode_utf16().count() as u32;
215 writer
216 .commit_transaction(&path, &replaced(&source, everything.clone(), replacement))
217 .unwrap();
218 let after = writer.read(&path, 1 << 20).unwrap();
219 assert_eq!(text(&after).2, replacement);
220 for file in &mut held {
221 let snapshot = onestore::read_snapshot(|offset, out| file.read_at(offset, out), 1 << 20)
222 .unwrap()
223 .unwrap();
224 assert_eq!(snapshot, after);
225 }
226 let error = writer
227 .commit_transaction(&path, &replaced(&source, 0..1, "X"))
228 .unwrap_err();
229 assert_eq!(error.state, CommitState::NotCommitted);
230 assert_eq!(error.error.kind(), io::ErrorKind::ResourceBusy);
231 assert_eq!(writer.read(&path, 1 << 20).unwrap(), after);
232 drop(held);
233
234 let stale = writer.open(&path, true).unwrap();
235 let maintenance = client();
236 let guard = maintenance.open(&path, false).unwrap();
237 let _: LockResponse = maintenance
238 .request(
239 Command::Lock,
240 LockRequest {
241 file_id: guard.id.unwrap(),
242 lock_sequence: 0,
243 locks: vec![
244 LockElement {
245 offset: 0xfffffffc,
246 length: 1,
247 flags: 0x12,
248 },
249 LockElement {
250 offset: 0xffffeffc,
251 length: 4096,
252 flags: 0x12,
253 },
254 ],
255 },
256 )
257 .unwrap();
258 let replacement_path = format!("{path}.replacement");
259 create(&maintenance, &replacement_path, &source);
260 let mut connection = maintenance
261 .connection
262 .lock()
263 .unwrap()
264 .as_ref()
265 .unwrap()
266 .clone();
267 maintenance
268 .runtime
269 .lock()
270 .unwrap()
271 .as_ref()
272 .unwrap()
273 .block_on(
274 maintenance
275 .tree
276 .rename(&mut connection, &path, &format!("{path}.old")),
277 )
278 .unwrap();
279 maintenance
280 .runtime
281 .lock()
282 .unwrap()
283 .as_ref()
284 .unwrap()
285 .block_on(
286 maintenance
287 .tree
288 .rename(&mut connection, &replacement_path, &path),
289 )
290 .unwrap();
291 drop(connection);
292 guard.close().unwrap();
293 let error = stale.coordinate(&path, true, &[]).err().unwrap();
294 assert_eq!(error.kind(), io::ErrorKind::ResourceBusy);
295 assert_eq!(writer.read(&path, 1 << 20).unwrap(), source);
296
297 let retiring = client();
298 let file = retiring
299 .open(&path, true)
300 .unwrap()
301 .coordinate(&path, true, &[])
302 .unwrap();
303 retiring.retire();
304 assert_eq!(
305 retiring.read(&path, 1 << 20).unwrap_err().kind(),
306 io::ErrorKind::NotConnected
307 );
308 drop(file);
309 let deadline = Instant::now() + Duration::from_secs(5);
310 loop {
311 match writer
312 .open(&path, true)
313 .and_then(|file| file.coordinate(&path, true, &[]))
314 {
315 Ok(file) => {
316 file.close().unwrap();
317 break;
318 }
319 Err(error)
320 if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < deadline =>
321 {
322 std::thread::sleep(Duration::from_millis(10))
323 }
324 Err(error) => panic!("retired connection retained locks: {error}"),
325 }
326 }
327
328 let mut unfinished = writer
329 .open(&path, true)
330 .unwrap()
331 .coordinate(&path, true, &[])
332 .unwrap();
333 assert_eq!(
334 unfinished
335 .write_at(source.len() as u64, b"unpublished")
336 .unwrap(),
337 11
338 );
339 unfinished.flush().unwrap();
340 unfinished.close().unwrap();
341 let snapshot = writer.read(&path, 1 << 20).unwrap();
342 assert_eq!(snapshot.len(), source.len() + 11);
343 assert_eq!(text(&snapshot).2, before);
344 writer
345 .commit_transaction(&path, &replaced(&snapshot, everything, "Recovered café 🦀"))
346 .unwrap();
347 let recovered = writer.read(&path, 1 << 20).unwrap();
348 assert_eq!(text(&recovered).2, "Recovered café 🦀");
349 let spare = client();
350 tokio::runtime::Builder::new_current_thread()
351 .build()
352 .unwrap()
353 .block_on(async {
354 drop(spare);
355 });
356 fs::write(std::path::Path::new(&output).join("source.one"), &source).unwrap();
357 fs::write(std::path::Path::new(&output).join("committed.one"), &after).unwrap();
358 fs::write(
359 std::path::Path::new(&output).join("recovered.one"),
360 &recovered,
361 )
362 .unwrap();
363 println!(
364 "{}",
365 serde_json::json!({"path":path,"readers":12,"committed_while_readers_held":true,"fresh_reads":12,"stale_snapshot_rejected":true,"replaced_handle_rejected":true,"retirement_releases_locks":true,"unpublished_tail_recovered":true,"drop_inside_runtime":true})
366 );
367}
368
369/// Polling reads only the header, even while maintenance excludes snapshot readers, and a
370/// transaction commits against the header it was built on.
371#[test]
372#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
373fn live_stamp_and_transaction() {
374 let poller = client();
375 let source = onestore::create_section("stamp.one", "Stamped", "Author").unwrap();
376 let path = format!(
377 "stamp-{}.one",
378 SystemTime::now()
379 .duration_since(UNIX_EPOCH)
380 .unwrap()
381 .as_nanos()
382 );
383 create(&poller, &path, &source);
384 assert_eq!(
385 poller.stamp(&path).unwrap(),
386 onestore::Stamp::of(&source).unwrap()
387 );
388 let maintenance = client();
389 let guard = maintenance.open(&path, false).unwrap();
390 guard.lock(0xfffffffb, 0x12).unwrap();
391 assert_eq!(
392 poller.read(&path, 1 << 20).unwrap_err().kind(),
393 io::ErrorKind::WouldBlock
394 );
395 assert_eq!(
396 poller.stamp(&path).unwrap(),
397 onestore::Stamp::of(&source).unwrap()
398 );
399 guard.close().unwrap();
400 let transaction = replaced(&source, 0..0, "Changed ");
401 let edited = applied(&source, &transaction);
402 poller.commit_transaction(&path, &transaction).unwrap();
403 assert_eq!(
404 poller.stamp(&path).unwrap(),
405 onestore::Stamp::of(&edited).unwrap()
406 );
407 assert_eq!(poller.read(&path, 1 << 20).unwrap(), edited);
408 let stale = poller.commit_transaction(&path, &transaction).unwrap_err();
409 assert_eq!(stale.state, CommitState::NotCommitted);
410 assert_eq!(stale.error.kind(), io::ErrorKind::ResourceBusy);
411 assert_eq!(poller.read(&path, 1 << 20).unwrap(), edited);
412 let base = onestore::Stamp::of(&edited).unwrap();
413 poller.confirm(&path, &base).unwrap();
414 let confirmed = poller.read(&path, 1 << 20).unwrap();
415 assert_eq!(confirmed[..212], edited[..212]);
416 assert_ne!(confirmed[212..252], edited[212..252]);
417 assert_eq!(confirmed[252..], edited[252..]);
418 let stale = poller.confirm(&path, &base).unwrap_err();
419 assert_eq!(stale.state, CommitState::NotCommitted);
420 assert_eq!(stale.error.kind(), io::ErrorKind::ResourceBusy);
421 poller.delete(&path).unwrap();
422}
423
424/// A watch names the file another client commits to in the watched folder.
425#[test]
426#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
427fn live_watch() {
428 let watcher = client();
429 let writer = client();
430 let folder = format!(
431 "watch-{}",
432 SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .unwrap()
435 .as_nanos()
436 );
437 writer.create_directory(&folder).unwrap();
438 let path = format!("{folder}/Watched.one");
439 let source = onestore::create_section("Watched.one", "Watched", "Author").unwrap();
440 create(&writer, &path, &source);
441 let (sender, changes) = std::sync::mpsc::channel();
442 watcher
443 .watch(&folder, move |changed| {
444 let _ = sender.send(changed.map_err(|error| error.kind()));
445 })
446 .unwrap();
447 std::thread::sleep(Duration::from_secs(1));
448 writer
449 .commit_transaction(&path, &replaced(&source, 0..0, "Changed "))
450 .unwrap();
451 let changed = changes.recv_timeout(Duration::from_secs(10)).unwrap();
452 assert!(changed.unwrap().contains(&"Watched.one".to_owned()));
453 // An idle watch hears nothing more.
454 while let Ok(changed) = changes.recv_timeout(Duration::from_secs(2)) {
455 assert!(changed.unwrap().iter().all(|path| path == "Watched.one"));
456 }
457 drop(watcher);
458 writer.delete(&path).unwrap();
459 writer.delete(&folder).unwrap();
460}
461
462#[test]
463#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
464fn live_storage_inspection() {
465 let reader = client();
466 // Stably unreadable, never contention: a protected section, and a file too damaged to say.
467 for (index, (fixture, unreadable)) in [
468 (
469 "native-encrypted/encrypted-01/notebook/synthetic.one",
470 io::ErrorKind::Unsupported,
471 ),
472 (
473 "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one",
474 io::ErrorKind::InvalidData,
475 ),
476 ]
477 .into_iter()
478 .enumerate()
479 {
480 let source = fs::read(format!("../../corpus/{fixture}")).unwrap();
481 let path = format!(
482 "inspection-{index}-{}.one",
483 SystemTime::now()
484 .duration_since(UNIX_EPOCH)
485 .unwrap()
486 .as_nanos()
487 );
488 create(&reader, &path, &source);
489 assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source);
490 assert_eq!(
491 reader.read(&path, source.len()).unwrap_err().kind(),
492 unreadable
493 );
494 let maintenance = client();
495 let guard = maintenance.open(&path, false).unwrap();
496 guard.lock(0xfffffffb, 0x12).unwrap();
497 assert_eq!(
498 reader.read_storage(&path, source.len()).unwrap_err().kind(),
499 io::ErrorKind::WouldBlock
500 );
501 guard.close().unwrap();
502 assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source);
503 }
504}
505
506#[test]
507#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
508fn live_assets() {
509 let reader = client();
510 let writer = client();
511 for size in [0, 1, 65535, 65536, 65537, 1048577] {
512 let bytes: Vec<_> = (0..size).map(|index| (index % 251) as u8).collect();
513 let path = format!(
514 "asset-{size}-{}.onebin",
515 SystemTime::now()
516 .duration_since(UNIX_EPOCH)
517 .unwrap()
518 .as_nanos()
519 );
520 create(&writer, &path, &bytes);
521 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
522 if size != 0 {
523 assert_eq!(
524 reader.read_asset(&path, size - 1).unwrap_err().kind(),
525 io::ErrorKind::FileTooLarge
526 );
527 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
528 }
529 let writing = writer.open(&path, true).unwrap();
530 assert_eq!(
531 reader.read_asset(&path, size).unwrap_err().kind(),
532 io::ErrorKind::WouldBlock
533 );
534 writing.close().unwrap();
535 let asset = reader.open_shared(&path, false, 1).unwrap();
536 assert_eq!(
537 writer.open(&path, true).err().unwrap().kind(),
538 io::ErrorKind::WouldBlock
539 );
540 let other = writer.open_shared(&path, false, 1).unwrap();
541 other.close().unwrap();
542 asset.close().unwrap();
543 writer.open(&path, true).unwrap().close().unwrap();
544 assert_eq!(reader.read_asset(&path, size).unwrap(), bytes);
545 }
546 assert_eq!(
547 reader
548 .read_asset("absent-payload.onebin", 0)
549 .unwrap_err()
550 .kind(),
551 io::ErrorKind::NotFound
552 );
553}
554
555#[test]
556#[ignore = "requires an owned Samba fixture and maintenance controller"]
557fn live_reader_hold() {
558 let client = client();
559 let path = std::env::var("ONESTORE_SMB_PATH").unwrap();
560 let output = std::path::PathBuf::from(std::env::var("ONESTORE_SMB_HOLD").unwrap());
561 assert!(!output.join("ready").exists() && !output.join("release").exists());
562 let mut file = client
563 .open(&path, false)
564 .unwrap()
565 .coordinate(&path, false, &[])
566 .unwrap();
567 fs::write(output.join("ready"), b"held").unwrap();
568 let deadline = Instant::now() + Duration::from_secs(300);
569 let mut accepted = 0;
570 let mut retries = 0;
571 while !output.join("release").exists() {
572 assert!(
573 Instant::now() < deadline,
574 "maintenance controller timed out"
575 );
576 match onestore::read_snapshot(|offset, out| file.read_at(offset, out), 256 << 20).unwrap() {
577 Some(_) => accepted += 1,
578 None => retries += 1,
579 }
580 std::thread::sleep(Duration::from_millis(5));
581 }
582 file.close().unwrap();
583 assert!(accepted > 0);
584 fs::write(
585 output.join("released.json"),
586 serde_json::to_vec(&serde_json::json!({"accepted": accepted, "retries": retries})).unwrap(),
587 )
588 .unwrap();
589}
590
591#[test]
592fn read_limits_respect_negotiation_and_available_credits() {
593 for dialect in Dialect::ALL {
594 for large_mtu in [false, true] {
595 for max_read_size in [65536, 65537, 131072, 1048576, u32::MAX] {
596 let params = NegotiatedParams {
597 dialect: *dialect,
598 max_read_size,
599 max_write_size: 65536,
600 max_transact_size: 65536,
601 server_guid: Default::default(),
602 signing_required: false,
603 capabilities: Capabilities(if large_mtu {
604 Capabilities::LARGE_MTU
605 } else {
606 0
607 }),
608 gmac_negotiated: false,
609 cipher: None,
610 compression_supported: false,
611 };
612 for credits in [0, 1, 2, 3, 15, 16, 17, u16::MAX] {
613 for requested in [1, 1024, 65535, 65536, 65537, 131072, 1048576, usize::MAX] {
614 let size = read_size(&params, credits, requested);
615 assert!(size > 0 && size <= requested && size <= max_read_size as usize);
616 assert!(size <= 1048576);
617 assert!(size.div_ceil(65536) <= usize::from(credits.max(1)));
618 if *dialect == Dialect::Smb2_0_2 || !large_mtu {
619 assert!(size <= 65536);
620 } else if credits >= 16 && max_read_size >= 1048576 && requested >= 1048576
621 {
622 assert_eq!(size, 1048576);
623 }
624 }
625 }
626 }
627 }
628 }
629}
630
631/// Notebook structure over the share: sections and groups created, renamed, coloured,
632/// ordered and deleted through `Notebook::open_smb`, seen again by a fresh discovery.
633/// The hidden attribute lands through the client, on a folder whose name Samba would not
634/// hide by itself too, and a notebook's art folder takes it; a replacing rename replaces.
635#[test]
636#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
637fn live_sidecar() {
638 let client = std::sync::Arc::new(client());
639 let root = format!("sidecar-{}", std::process::id());
640 client.create_directory(&root).unwrap();
641 let attributes = |name: &str| {
642 client
643 .read_dir(&root, 100)
644 .unwrap()
645 .into_iter()
646 .find(|entry| entry.name == name)
647 .unwrap()
648 .attributes
649 };
650 let plain = format!("{root}/plain");
651 client.create_directory(&plain).unwrap();
652 assert_eq!(attributes("plain") & HIDDEN, 0);
653 client.hide(&plain).unwrap();
654 client.hide(&plain).unwrap();
655 assert_eq!(attributes("plain") & (HIDDEN | 0x10), HIDDEN | 0x10);
656 let cache = tempfile::tempdir().unwrap();
657 let notebook =
658 crate::session::Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path())
659 .unwrap();
660 let mapped = notebook
661 .map_tag_art("Launch", 13, b"rocket", "png")
662 .unwrap();
663 assert_eq!(attributes(".snowbound") & HIDDEN, HIDDEN);
664 assert_eq!(notebook.tag_art().unwrap(), mapped);
665 assert_eq!(notebook.tag_art_file(&mapped[0].art).unwrap(), b"rocket");
666 let [first, second] = ["first", "second"].map(|name| format!("{root}/{name}"));
667 client.create(&first, b"first").unwrap();
668 client.create(&second, b"second").unwrap();
669 assert_eq!(
670 client.rename(&first, &second).unwrap_err().kind(),
671 io::ErrorKind::AlreadyExists
672 );
673 client.replace(&first, &second).unwrap();
674 assert_eq!(client.read_asset(&second, 100).unwrap(), b"first");
675 if std::env::var_os("ONESTORE_SMB_KEEP").is_none() {
676 client.delete(&second).unwrap();
677 client.delete(&plain).unwrap();
678 for file in client
679 .read_dir(&format!("{root}/.snowbound/tags"), 100)
680 .unwrap()
681 {
682 if file.attributes & 0x10 == 0 {
683 client
684 .delete(&format!("{root}/.snowbound/tags/{}", file.name))
685 .unwrap();
686 }
687 }
688 for path in [".snowbound/tags", ".snowbound/tags.json", ".snowbound"] {
689 client.delete(&format!("{root}/{path}")).unwrap();
690 }
691 client.delete(&root).unwrap();
692 }
693}
694
695#[test]
696#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
697fn live_structure() {
698 use crate::session::Notebook;
699 let client = std::sync::Arc::new(client());
700 let root = format!("structure-{}", std::process::id());
701 client.create_directory(&root).unwrap();
702 let first = onestore::create_section("First.one", "First page", "Author").unwrap();
703 client.create(&format!("{root}/First.one"), &first).unwrap();
704 let first_id = onestore::Store::parse(&first).unwrap().header.file_id;
705 let toc =
706 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", first_id)])
707 .unwrap();
708 client
709 .create(&format!("{root}/Open Notebook.onetoc2"), &toc)
710 .unwrap();
711 let toc_id = onestore::Store::parse(&toc).unwrap().header.file_id;
712 client
713 .place(&format!("{root}/First.one"), toc_id, "First.one")
714 .unwrap();
715 let cache = tempfile::tempdir().unwrap();
716 let mut notebook =
717 Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap();
718 assert_eq!(
719 notebook
720 .create_section(
721 "",
722 "Second",
723 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
724 )
725 .unwrap(),
726 "Second.one"
727 );
728 assert_eq!(notebook.create_group("", "Archive").unwrap(), "Archive");
729 assert_eq!(
730 notebook
731 .create_section(
732 "Archive",
733 "Inner",
734 &onestore::PageCreation::new(None, Some(""), "Author").unwrap()
735 )
736 .unwrap(),
737 "Archive/Inner.one"
738 );
739 assert_eq!(
740 notebook.rename("Second.one", "Renamed").unwrap(),
741 "Renamed.one"
742 );
743 assert_eq!(notebook.rename("Archive", "Kept").unwrap(), "Kept");
744 notebook
745 .set_section_color("Renamed.one", Some(0x5ed7ff))
746 .unwrap();
747 notebook.reorder("", &["Kept", "Renamed.one"]).unwrap();
748 notebook.delete("First.one").unwrap();
749 let names = |folder: &crate::discover::Folder| {
750 (
751 folder
752 .sections
753 .iter()
754 .map(|s| s.path.clone())
755 .collect::<Vec<_>>(),
756 folder
757 .groups
758 .iter()
759 .map(|g| g.path.clone())
760 .collect::<Vec<_>>(),
761 )
762 };
763 let fresh = Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap();
764 assert_eq!(
765 names(fresh.catalog()),
766 (
767 vec!["Renamed.one".to_owned()],
768 vec!["Kept".to_owned(), "OneNote_RecycleBin".to_owned()]
769 )
770 );
771 assert_eq!(
772 names(&fresh.catalog().groups[0]),
773 (vec!["Kept/Inner.one".to_owned()], vec![])
774 );
775 assert_eq!(
776 names(&fresh.catalog().groups[1]),
777 (vec!["OneNote_RecycleBin/First.one".to_owned()], vec![])
778 );
779 assert!(fresh.catalog().toc.as_ref().unwrap().unresolved.is_empty());
780 let renamed = client
781 .read_storage(&format!("{root}/Renamed.one"), 1 << 20)
782 .unwrap();
783 let header = onestore::Store::parse(&renamed).unwrap().header;
784 assert_eq!(header.ancestor, toc_id);
785 assert_eq!(header.name_crc, 0x6108912a);
786 assert!(fresh.section("Renamed.one", || {}).is_err());
787 // A password supersedes the file under writer coordination, never over a newer stamp, and
788 // while a reader holds it open, as OneNote's readers share it with deletion.
789 let section = format!("{root}/Renamed.one");
790 let written = format!("{root}/.Renamed.one.snowbound");
791 client.create(&written, &renamed).unwrap();
792 let mut stale = onestore::Stamp::of(&renamed).unwrap();
793 stale.length += 1;
794 let refused = client.supersede(&section, &stale, &written).unwrap_err();
795 assert_eq!(refused.state, CommitState::NotCommitted);
796 assert_eq!(client.read_storage(&section, 1 << 20).unwrap(), renamed);
797 client.delete(&written).unwrap();
798 let mut fresh = fresh;
799 let reader = client.open(&section, false).unwrap();
800 let key = fresh
801 .set_password("Renamed.one", None, Some("secret"))
802 .unwrap()
803 .unwrap();
804 reader.close().unwrap();
805 let protected = client.read_storage(&section, 1 << 20).unwrap();
806 assert_eq!(
807 onestore::protected::Key::open(&protected, "secret")
808 .unwrap()
809 .secret(),
810 key.secret()
811 );
812 assert!(matches!(
813 fresh.catalog().sections[0].state,
814 crate::discover::SectionState::Locked
815 ));
816 let names: Vec<String> = client
817 .read_dir(&root, 100)
818 .unwrap()
819 .into_iter()
820 .map(|entry| entry.name)
821 .collect();
822 assert!(!names.iter().any(|name| name.starts_with('.')), "{names:?}");
823 for path in [
824 "Renamed.one",
825 "Kept/Inner.one",
826 "Kept/Open Notebook.onetoc2",
827 "Kept",
828 "OneNote_RecycleBin/First.one",
829 "OneNote_RecycleBin/Open Notebook.onetoc2",
830 "OneNote_RecycleBin",
831 "Open Notebook.onetoc2",
832 ] {
833 client.delete(&format!("{root}/{path}")).unwrap();
834 }
835 client.delete(&root).unwrap();
836}
837
838/// Renames a notebook folder of its own on the share, signing in as `ONESTORE_SMB_LAB_USER`
839/// where set: refused while another client holds OneNote's writer locks on a section, then
840/// done with every file as it was.
841#[test]
842#[ignore = "requires an owned Samba share at ONESTORE_SMB_LAB"]
843fn live_folder_rename() {
844 use crate::session::Notebook;
845 let (user, password) = (
846 std::env::var("ONESTORE_SMB_LAB_USER").unwrap_or_default(),
847 std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(),
848 );
849 let connect = || {
850 let credentials = Credentials {
851 username: &user,
852 password: &password,
853 domain: "",
854 };
855 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
856 Client::connect(&address, "agent", credentials, Duration::from_secs(10)).unwrap()
857 };
858 let (client, other) = (std::sync::Arc::new(connect()), connect());
859 let parent = format!("rename-{}", std::process::id());
860 let (root, renamed) = (format!("{parent}/Before"), format!("{parent}/After"));
861 client.create_directory(&parent).unwrap();
862 client.create_directory(&root).unwrap();
863 let section = onestore::create_section("First.one", "First page", "Author").unwrap();
864 client
865 .create(&format!("{root}/First.one"), &section)
866 .unwrap();
867 let id = onestore::Store::parse(&section).unwrap().header.file_id;
868 let toc =
869 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("First.one", id)]).unwrap();
870 client
871 .create(&format!("{root}/Open Notebook.onetoc2"), &toc)
872 .unwrap();
873 let cache = tempfile::tempdir().unwrap();
874 let open = || Notebook::open_smb(std::sync::Arc::clone(&client), &root, cache.path()).unwrap();
875 let held = format!("{root}/First.one");
876 let writer = other
877 .open(&held, true)
878 .unwrap()
879 .coordinate(&held, true, &[])
880 .unwrap();
881 let refused = open().rename_folder("After").unwrap_err();
882 assert!(
883 matches!(&refused, crate::Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock),
884 "{refused}"
885 );
886 assert!(client.read_dir(&root, 10).is_ok());
887 writer.close().unwrap();
888 let to = open().rename_folder("After").unwrap();
889 assert_eq!(to, client.location(&renamed));
890 assert!(client.read_dir(&root, 10).is_err());
891 assert_eq!(
892 client
893 .read_storage(&format!("{renamed}/First.one"), 1 << 20)
894 .unwrap(),
895 section
896 );
897 for path in ["First.one", "Open Notebook.onetoc2"] {
898 client.delete(&format!("{renamed}/{path}")).unwrap();
899 }
900 client.delete(&renamed).unwrap();
901 client.delete(&parent).unwrap();
902}
903
904/// Signs in as `ONESTORE_SMB_LAB_USER` with `ONESTORE_SMB_LAB_PASSWORD` where set, else as a
905/// guest, to a server with a share `agent`; only lists and reads.
906#[test]
907#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba with a share `agent`"]
908fn live_refusals_name_their_remedy() {
909 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
910 let user = std::env::var("ONESTORE_SMB_LAB_USER").unwrap_or_default();
911 let password = std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default();
912 let timeout = Duration::from_secs(10);
913 let account = || Credentials {
914 username: &user,
915 password: &password,
916 domain: "",
917 };
918 assert!(
919 shares(&address, account(), timeout)
920 .unwrap()
921 .contains(&"agent".to_owned())
922 );
923 let refusal = |result: io::Result<Client>| Refusal::of(&result.err().unwrap());
924 assert_eq!(
925 refusal(Client::connect(&address, "missing", account(), timeout)),
926 Refusal::NoShare
927 );
928 assert_eq!(
929 refusal(Client::connect("127.0.0.1:9", "agent", account(), timeout)),
930 Refusal::Unreachable
931 );
932 let client = Client::connect(&address, "agent", account(), timeout).unwrap();
933 assert_eq!(
934 Refusal::of(&client.read_dir("missing-folder", 10).unwrap_err()),
935 Refusal::NoFolder
936 );
937 if !user.is_empty() {
938 let wrong = Credentials {
939 password: "not the password",
940 ..account()
941 };
942 assert_eq!(
943 refusal(Client::connect(&address, "agent", wrong, timeout)),
944 Refusal::SignIn
945 );
946 }
947}
948
949/// A server that answers only SMB1: it hangs up on SMB2's negotiation and agrees to NT LM
950/// 0.12, as Windows XP or Samba held to NT1 do.
951#[test]
952fn an_smb1_only_server_is_named() {
953 use std::io::{Read, Write};
954 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
955 let address = listener.local_addr().unwrap().to_string();
956 let server = std::thread::spawn(move || {
957 for _ in 0..2 {
958 let (mut stream, _) = listener.accept().unwrap();
959 let mut length = [0; 4];
960 stream.read_exact(&mut length).unwrap();
961 let mut message = vec![0; u32::from_be_bytes(length) as usize];
962 stream.read_exact(&mut message).unwrap();
963 if message.starts_with(b"\xffSMB") {
964 let mut reply = b"\xffSMB\x72".to_vec();
965 reply.resize(32, 0);
966 reply.extend_from_slice(&[17, 0, 0]);
967 reply.resize(32 + 1 + 34 + 2, 0);
968 stream
969 .write_all(&(reply.len() as u32).to_be_bytes())
970 .unwrap();
971 stream.write_all(&reply).unwrap();
972 }
973 }
974 });
975 let error = Client::connect(
976 &address,
977 "share",
978 Credentials::default(),
979 Duration::from_secs(5),
980 )
981 .err()
982 .unwrap();
983 server.join().unwrap();
984 assert_eq!(Refusal::of(&error), Refusal::Smb1);
985}
986
987#[test]
988#[ignore = "requires ONESTORE_SMB1_LAB pointing to a disposable Samba held to SMB1 (NT1)"]
989fn live_smb1_only_server_is_named() {
990 let error = Client::connect(
991 &std::env::var("ONESTORE_SMB1_LAB").unwrap(),
992 "agent",
993 Credentials::default(),
994 Duration::from_secs(10),
995 )
996 .err()
997 .unwrap();
998 assert_eq!(Refusal::of(&error), Refusal::Smb1, "{error}");
999}
1000
1001fn corpus(path: &str) -> Vec<u8> {
1002 fs::read(format!(
1003 "{}/../../corpus/{path}",
1004 env!("CARGO_MANIFEST_DIR")
1005 ))
1006 .unwrap()
1007}
1008
1009fn from(bytes: &[u8]) -> impl FnMut(u64, &mut [u8]) -> io::Result<usize> + '_ {
1010 |offset, output| {
1011 let rest = bytes.get(offset as usize..).unwrap_or_default();
1012 let count = rest.len().min(output.len());
1013 output[..count].copy_from_slice(&rest[..count]);
1014 Ok(count)
1015 }
1016}
1017
1018#[test]
1019fn a_password_protected_section_is_not_contention() {
1020 let bytes = corpus("native-encrypted/encrypted-01/notebook/synthetic.one");
1021 let error = snapshot(from(&bytes), 1 << 20).unwrap_err();
1022 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
1023 let valid = corpus("outline-edit/before/notebook/synthetic.one");
1024 assert_eq!(snapshot(from(&valid), 1 << 20).unwrap(), valid);
1025}
1026
1027#[test]
1028fn a_stably_invalid_file_is_not_contention() {
1029 // OneNote's stub: consistent storage whose object space has no revisions yet.
1030 let bytes = corpus("native-encrypted/cold-encrypted-02/notebook/Open Notebook.one");
1031 let error = snapshot(from(&bytes), 1 << 20).unwrap_err();
1032 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1033}
1034
1035#[test]
1036fn a_torn_read_is_contention() {
1037 let bytes = corpus("outline-edit/before/notebook/synthetic.one");
1038 let mut headers = 0;
1039 let torn = |offset: u64, output: &mut [u8]| {
1040 let count = from(&bytes)(offset, output)?;
1041 if offset == 0 {
1042 headers += 1;
1043 output[1023] ^= headers as u8;
1044 }
1045 Ok(count)
1046 };
1047 let error = snapshot(torn, 1 << 20).unwrap_err();
1048 assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
1049}
1050
1051#[test]
1052fn unreachable_server_is_not_connected() {
1053 use crate::session::{SyncState, SyncStatus};
1054 let state = |error: smb2::Error| {
1055 SyncStatus {
1056 synced: Some(1),
1057 error: Some(io_error(error)),
1058 queued: 1,
1059 }
1060 .state()
1061 };
1062 for kind in [
1063 io::ErrorKind::ConnectionRefused,
1064 io::ErrorKind::HostUnreachable,
1065 io::ErrorKind::TimedOut,
1066 ] {
1067 assert_eq!(
1068 state(smb2::Error::Io(kind.into())),
1069 SyncState::NotConnected,
1070 "{kind}"
1071 );
1072 }
1073 assert_eq!(state(smb2::Error::Disconnected), SyncState::NotConnected);
1074 assert_eq!(state(smb2::Error::Timeout), SyncState::NotConnected);
1075 let denied = smb2::Error::Protocol {
1076 status: smb2::types::status::NtStatus(0xc0000022),
1077 command: Command::Create,
1078 };
1079 assert_eq!(io_error(denied).kind(), io::ErrorKind::Other);
1080}