| author | |
| committer | |
| log | 2c75dd41e5584f3d01c9253e50bdebec6623740f |
| tree | d36e782b95ee59ac59dbece4b3fd347754bbd33e |
| parent | d7d1c96c52f172bdfa45f44531559f14750e55f9 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Add share-relative directory enumeration with complete pagination, exact Unicode names, observed metadata, entry limits, and explicit access/path errors. Interrupted pages, duplicate names, and failed close do not return a partial list.
Validate 4,004 entries against an independent Linux filesystem oracle, interrupt first/second/tenth request and response pages and close, then reconnect and repeat. Add a reproducible caller-owned VM harness and bounded record/truncation tests.
Validation: four unit tests, one doctest, nine live directory cases, Clippy, and iOS device/simulator linking. Disposable lab removed.
Assisted-by: gpt-6-astra5 files changed, 413 insertions(+), 0 deletions(-)
crates/onestore-smb/README.md+15| ... | @@ -38,6 +38,21 @@ identity is checked after acquiring the guards. Connection loss retires the | ... | @@ -38,6 +38,21 @@ identity is checked after acquiring the guards. Connection loss retires the |
| 38 | client. Reconnect for subsequent operations, and reconcile an `Unknown` edit | 38 | client. Reconnect for subsequent operations, and reconcile an `Unknown` edit |
| 39 | before retrying it. The transport does not automatically replay requests. | 39 | before retrying it. The transport does not automatically replay requests. |
| 40 | 40 | ||
| 41 | `Client::read_dir(path, entry_limit)` enumerates a directory, including the share | ||
| 42 | root with an empty path. It follows every response page and returns no partial | ||
| 43 | list on interruption, entry-limit overflow or close failure. Entries retain exact | ||
| 44 | Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse | ||
| 45 | flags. Concurrent directory changes are not an atomic snapshot; repeated names | ||
| 46 | are rejected with `ResourceBusy`. Notebook identities come from the files, not | ||
| 47 | directory names or sizes. Missing paths, denied access and non-directory paths | ||
| 48 | have distinct I/O error kinds. | ||
| 49 | |||
| 50 | `python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable | ||
| 51 | Linux lab VM against its filesystem listing and interrupts directory requests, | ||
| 52 | responses and close. It creates synthetic files in that VM; the caller retains | ||
| 53 | responsibility for VM teardown. The parser also has bounded-record/truncation | ||
| 54 | tests independent of the server. | ||
| 55 | |||
| 41 | Device and simulator builds link for iOS. Native acceptance uses disposable | 56 | Device and simulator builds link for iOS. Native acceptance uses disposable |
| 42 | OneNote 2010 clients and Samba; it does not establish on-device execution or | 57 | OneNote 2010 clients and Samba; it does not establish on-device execution or |
| 43 | physical power-loss durability. | 58 | physical power-loss durability. |
crates/onestore-smb/src/directory.rs created+219| ... | @@ -0,0 +1,219 @@ | ||
| 1 | use super::*; | ||
| 2 | use smb2::msg::query_directory::{ | ||
| 3 | FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse, | ||
| 4 | }; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | /// Observed directory metadata, not a stable notebook identity or a file snapshot. | ||
| 8 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 9 | pub struct DirectoryEntry { | ||
| 10 | pub name: String, | ||
| 11 | pub size: u64, | ||
| 12 | /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400. | ||
| 13 | pub attributes: u32, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Client { | ||
| 17 | /// Enumerates a share-relative directory completely or returns an error without a partial list. | ||
| 18 | /// An empty path selects the share root. The limit excludes `.` and `..`. | ||
| 19 | /// Concurrent directory changes are not an atomic snapshot; repeated names return ResourceBusy. | ||
| 20 | pub fn read_dir(&self, path: &str, limit: usize) -> io::Result<Vec<DirectoryEntry>> { | ||
| 21 | if path.contains('\0') || path.encode_utf16().count() > 32767 { | ||
| 22 | return Err(io::ErrorKind::InvalidInput.into()); | ||
| 23 | } | ||
| 24 | let response: CreateResponse = self.request( | ||
| 25 | Command::Create, | ||
| 26 | CreateRequest { | ||
| 27 | requested_oplock_level: OplockLevel::None, | ||
| 28 | impersonation_level: ImpersonationLevel::Impersonation, | ||
| 29 | desired_access: FileAccessMask::new(0x80000000), | ||
| 30 | file_attributes: 0, | ||
| 31 | share_access: ShareAccess(7), | ||
| 32 | create_disposition: CreateDisposition::FileOpen, | ||
| 33 | create_options: 0x1, | ||
| 34 | name: smb2::encode_path(&path.replace('\\', "/")), | ||
| 35 | create_contexts: Vec::new(), | ||
| 36 | }, | ||
| 37 | )?; | ||
| 38 | let file = File { | ||
| 39 | client: self, | ||
| 40 | id: Some(response.file_id), | ||
| 41 | }; | ||
| 42 | let mut entries = BTreeMap::new(); | ||
| 43 | loop { | ||
| 44 | let response: io::Result<QueryDirectoryResponse> = | ||
| 45 | self.request_with(Command::QueryDirectory, |connection| { | ||
| 46 | ( | ||
| 47 | QueryDirectoryRequest { | ||
| 48 | file_information_class: FileInformationClass::FileDirectoryInformation, | ||
| 49 | flags: QueryDirectoryFlags(if entries.is_empty() { 1 } else { 0 }), | ||
| 50 | file_index: 0, | ||
| 51 | file_id: file.id.unwrap(), | ||
| 52 | output_buffer_length: connection | ||
| 53 | .params() | ||
| 54 | .expect("connected SMB session is negotiated") | ||
| 55 | .max_transact_size | ||
| 56 | .min(65536), | ||
| 57 | file_name: "*".into(), | ||
| 58 | }, | ||
| 59 | CreditCharge(1), | ||
| 60 | ) | ||
| 61 | }); | ||
| 62 | let response = match response { | ||
| 63 | Ok(response) => response, | ||
| 64 | Err(error) | ||
| 65 | if matches!( | ||
| 66 | error.get_ref().and_then(|error| error.downcast_ref::<smb2::Error>()), | ||
| 67 | Some(smb2::Error::Protocol { status, command: Command::QueryDirectory }) | ||
| 68 | if status.0 == 0x80000006 || (entries.is_empty() && status.0 == 0xc000000f) | ||
| 69 | ) => | ||
| 70 | { | ||
| 71 | break; | ||
| 72 | } | ||
| 73 | Err(error) => return Err(error), | ||
| 74 | }; | ||
| 75 | for entry in decode(&response.output_buffer)? { | ||
| 76 | if entries | ||
| 77 | .insert(entry.name, (entry.size, entry.attributes)) | ||
| 78 | .is_some() | ||
| 79 | { | ||
| 80 | return Err(io::ErrorKind::ResourceBusy.into()); | ||
| 81 | } | ||
| 82 | if entries.len() | ||
| 83 | - usize::from(entries.contains_key(".")) | ||
| 84 | - usize::from(entries.contains_key("..")) | ||
| 85 | > limit | ||
| 86 | { | ||
| 87 | return Err(io::ErrorKind::FileTooLarge.into()); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | file.close()?; | ||
| 92 | Ok(entries | ||
| 93 | .into_iter() | ||
| 94 | .filter(|(name, _)| name != "." && name != "..") | ||
| 95 | .map(|(name, (size, attributes))| DirectoryEntry { | ||
| 96 | name, | ||
| 97 | size, | ||
| 98 | attributes, | ||
| 99 | }) | ||
| 100 | .collect()) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> { | ||
| 105 | if bytes.len() > 65536 { | ||
| 106 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 107 | } | ||
| 108 | let mut entries = Vec::new(); | ||
| 109 | loop { | ||
| 110 | if bytes.len() < 64 { | ||
| 111 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 112 | } | ||
| 113 | let next = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; | ||
| 114 | let length = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize; | ||
| 115 | let end = 64usize | ||
| 116 | .checked_add(length) | ||
| 117 | .ok_or(io::ErrorKind::InvalidData)?; | ||
| 118 | if length == 0 | ||
| 119 | || !length.is_multiple_of(2) | ||
| 120 | || end > bytes.len() | ||
| 121 | || (next != 0 && (next < end || !next.is_multiple_of(8) || next >= bytes.len())) | ||
| 122 | || (next == 0 && bytes.len() - end > 7) | ||
| 123 | { | ||
| 124 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 125 | } | ||
| 126 | let units: Vec<_> = bytes[64..end] | ||
| 127 | .chunks_exact(2) | ||
| 128 | .map(|unit| u16::from_le_bytes([unit[0], unit[1]])) | ||
| 129 | .collect(); | ||
| 130 | let name = String::from_utf16(&units).map_err(|_| io::ErrorKind::InvalidData)?; | ||
| 131 | if name.contains(['\0', '/', '\\']) { | ||
| 132 | return Err(io::ErrorKind::InvalidData.into()); | ||
| 133 | } | ||
| 134 | let size = i64::from_le_bytes(bytes[40..48].try_into().unwrap()); | ||
| 135 | entries.push(DirectoryEntry { | ||
| 136 | name, | ||
| 137 | size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?, | ||
| 138 | attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()), | ||
| 139 | }); | ||
| 140 | if next == 0 { | ||
| 141 | return Ok(entries); | ||
| 142 | } | ||
| 143 | bytes = &bytes[next..]; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | #[cfg(test)] | ||
| 148 | mod tests { | ||
| 149 | use super::*; | ||
| 150 | |||
| 151 | fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> { | ||
| 152 | let mut bytes = vec![0; 64]; | ||
| 153 | bytes[40..48].copy_from_slice(&size.to_le_bytes()); | ||
| 154 | bytes[56..60].copy_from_slice(&attributes.to_le_bytes()); | ||
| 155 | let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); | ||
| 156 | bytes[60..64].copy_from_slice(&(name.len() as u32).to_le_bytes()); | ||
| 157 | bytes.extend(name); | ||
| 158 | bytes | ||
| 159 | } | ||
| 160 | |||
| 161 | #[test] | ||
| 162 | fn directory_records_preserve_names_sizes_and_attributes() { | ||
| 163 | let mut bytes = Vec::new(); | ||
| 164 | let mut expected = Vec::new(); | ||
| 165 | for i in 0..300u32 { | ||
| 166 | let name = format!("Section {i} š¦ e\u{301}.one"); | ||
| 167 | let size = u64::from(i) * 100_000_000; | ||
| 168 | let attributes = if i % 3 == 0 { 0x410 } else { 0x20 }; | ||
| 169 | let mut entry = record(&name, size, attributes); | ||
| 170 | if i != 299 { | ||
| 171 | entry.resize(entry.len().next_multiple_of(8), 0xa5); | ||
| 172 | let length = entry.len() as u32; | ||
| 173 | entry[..4].copy_from_slice(&length.to_le_bytes()); | ||
| 174 | } | ||
| 175 | bytes.extend(entry); | ||
| 176 | expected.push(DirectoryEntry { | ||
| 177 | name, | ||
| 178 | size, | ||
| 179 | attributes, | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | assert_eq!(decode(&bytes).unwrap(), expected); | ||
| 183 | for end in 0..bytes.len() { | ||
| 184 | assert!(decode(&bytes[..end]).is_err(), "accepted prefix {end}"); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | #[test] | ||
| 189 | fn invalid_directory_records_never_become_partial_results() { | ||
| 190 | assert!(decode(&vec![0; 65537]).is_err()); | ||
| 191 | for name in ["", "bad\0name", "a/b", "a\\b"] { | ||
| 192 | assert!(decode(&record(name, 0, 0)).is_err()); | ||
| 193 | } | ||
| 194 | let valid = record("a.one", 12, 0x20); | ||
| 195 | for (at, value) in [ | ||
| 196 | (0, 8), | ||
| 197 | (0, 65), | ||
| 198 | (0, 72), | ||
| 199 | (0, u32::MAX), | ||
| 200 | (60, 0), | ||
| 201 | (60, 1), | ||
| 202 | (60, u32::MAX), | ||
| 203 | (44, u32::MAX), | ||
| 204 | ] { | ||
| 205 | let mut bytes = valid.clone(); | ||
| 206 | bytes[at..at + 4].copy_from_slice(&value.to_le_bytes()); | ||
| 207 | assert!(decode(&bytes).is_err(), "offset={at} value={value}"); | ||
| 208 | } | ||
| 209 | let mut bytes = valid.clone(); | ||
| 210 | bytes[64..66].copy_from_slice(&0xd800u16.to_le_bytes()); | ||
| 211 | assert!(decode(&bytes).is_err()); | ||
| 212 | let mut bytes = valid; | ||
| 213 | bytes.extend_from_slice(&[0; 8]); | ||
| 214 | assert!(decode(&bytes).is_err()); | ||
| 215 | for name in [".", ".."] { | ||
| 216 | assert_eq!(decode(&record(name, 0, 0x10)).unwrap()[0].name, name); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
crates/onestore-smb/src/lib.rs+5| ... | @@ -25,6 +25,9 @@ use smb2::{ | ... | @@ -25,6 +25,9 @@ use smb2::{ |
| 25 | use std::{io, ops::Range, sync::Mutex, time::Duration}; | 25 | use std::{io, ops::Range, sync::Mutex, time::Duration}; |
| 26 | use tokio::runtime::{Handle, Runtime}; | 26 | use tokio::runtime::{Handle, Runtime}; |
| 27 | 27 | ||
| 28 | mod directory; | ||
| 29 | pub use directory::DirectoryEntry; | ||
| 30 | |||
| 28 | #[derive(Default)] | 31 | #[derive(Default)] |
| 29 | pub struct Credentials<'a> { | 32 | pub struct Credentials<'a> { |
| 30 | pub username: &'a str, | 33 | pub username: &'a str, |
| ... | @@ -153,6 +156,8 @@ impl Client { | ... | @@ -153,6 +156,8 @@ impl Client { |
| 153 | 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, | 156 | 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock, |
| 154 | 0xc0000011 => io::ErrorKind::UnexpectedEof, | 157 | 0xc0000011 => io::ErrorKind::UnexpectedEof, |
| 155 | 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, | 158 | 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound, |
| 159 | 0xc0000022 => io::ErrorKind::PermissionDenied, | ||
| 160 | 0xc0000103 => io::ErrorKind::NotADirectory, | ||
| 156 | _ => io::ErrorKind::Other, | 161 | _ => io::ErrorKind::Other, |
| 157 | }; | 162 | }; |
| 158 | return Err(io::Error::new( | 163 | return Err(io::Error::new( |
crates/onestore-smb/src/tests.rs+81| ... | @@ -10,6 +10,87 @@ use std::{ | ... | @@ -10,6 +10,87 @@ use std::{ |
| 10 | 10 | ||
| 11 | mod faults; | 11 | mod faults; |
| 12 | 12 | ||
| 13 | #[test] | ||
| 14 | #[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"] | ||
| 15 | fn live_directory() { | ||
| 16 | let client = client(); | ||
| 17 | let bytes = fs::read(std::env::var("ONESTORE_SMB_DIRECTORY_ORACLE").unwrap()).unwrap(); | ||
| 18 | let oracle: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); | ||
| 19 | let root = oracle["path"].as_str().unwrap(); | ||
| 20 | let expected = oracle["entries"].as_array().unwrap(); | ||
| 21 | let entries = client.read_dir(root, expected.len()).unwrap(); | ||
| 22 | assert_eq!(entries.len(), expected.len()); | ||
| 23 | for expected in expected { | ||
| 24 | let entry = entries | ||
| 25 | .iter() | ||
| 26 | .find(|entry| entry.name == expected["name"]) | ||
| 27 | .unwrap(); | ||
| 28 | let directory = expected["directory"].as_bool().unwrap(); | ||
| 29 | assert_eq!(entry.attributes & 0x10 != 0, directory, "{}", entry.name); | ||
| 30 | if !directory { | ||
| 31 | assert_eq!( | ||
| 32 | entry.size, | ||
| 33 | expected["size"].as_u64().unwrap(), | ||
| 34 | "{}", | ||
| 35 | entry.name | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | assert_eq!( | ||
| 40 | client.read_dir(root, entries.len() - 1).unwrap_err().kind(), | ||
| 41 | io::ErrorKind::FileTooLarge | ||
| 42 | ); | ||
| 43 | assert_eq!(client.read_dir(root, entries.len()).unwrap(), entries); | ||
| 44 | assert!( | ||
| 45 | client | ||
| 46 | .read_dir(&format!("{root}/empty"), 0) | ||
| 47 | .unwrap() | ||
| 48 | .is_empty() | ||
| 49 | ); | ||
| 50 | assert_eq!( | ||
| 51 | client | ||
| 52 | .read_dir(&format!("{root}/missing"), 1) | ||
| 53 | .unwrap_err() | ||
| 54 | .kind(), | ||
| 55 | io::ErrorKind::NotFound | ||
| 56 | ); | ||
| 57 | assert_eq!( | ||
| 58 | client | ||
| 59 | .read_dir(&format!("{root}/file.one"), 1) | ||
| 60 | .unwrap_err() | ||
| 61 | .kind(), | ||
| 62 | io::ErrorKind::NotADirectory | ||
| 63 | ); | ||
| 64 | assert_eq!( | ||
| 65 | client | ||
| 66 | .read_dir(&format!("{root}/denied"), 1) | ||
| 67 | .unwrap_err() | ||
| 68 | .kind(), | ||
| 69 | io::ErrorKind::PermissionDenied | ||
| 70 | ); | ||
| 71 | assert!( | ||
| 72 | client | ||
| 73 | .read_dir("", 10_000) | ||
| 74 | .unwrap() | ||
| 75 | .iter() | ||
| 76 | .any(|entry| entry.name == root) | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | #[test] | ||
| 81 | #[ignore = "requires an owned Samba directory through a proxy that interrupts a later directory page or close"] | ||
| 82 | fn live_directory_interruption() { | ||
| 83 | let client = client(); | ||
| 84 | let root = std::env::var("ONESTORE_SMB_DIRECTORY").unwrap(); | ||
| 85 | let started = Instant::now(); | ||
| 86 | assert!(client.read_dir(&root, 10_000).is_err()); | ||
| 87 | assert!(started.elapsed() < Duration::from_secs(10)); | ||
| 88 | assert_eq!( | ||
| 89 | client.read_dir(&root, 10_000).unwrap_err().kind(), | ||
| 90 | io::ErrorKind::NotConnected | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 13 | fn client() -> Client { | 94 | fn client() -> Client { |
| 14 | Client::connect( | 95 | Client::connect( |
| 15 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | 96 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), |
tools/test_smb_directory.py created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Compare SMB enumeration with a disposable Linux VM's filesystem and interrupt pagination.""" | ||
| 3 | import argparse | ||
| 4 | import json | ||
| 5 | import os | ||
| 6 | from pathlib import Path | ||
| 7 | import socket | ||
| 8 | import subprocess | ||
| 9 | import sys | ||
| 10 | import time | ||
| 11 | import uuid | ||
| 12 | |||
| 13 | sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7')) | ||
| 14 | from linux_vm import load_instance, ssh_argv | ||
| 15 | |||
| 16 | |||
| 17 | def main(): | ||
| 18 | parser = argparse.ArgumentParser(description=__doc__) | ||
| 19 | parser.add_argument('vm', help='An already running, caller-owned Linux lab VM') | ||
| 20 | parser.add_argument('output', type=Path) | ||
| 21 | args = parser.parse_args() | ||
| 22 | output = args.output.resolve() | ||
| 23 | output.mkdir(parents=True, exist_ok=False) | ||
| 24 | config = load_instance(args.vm) | ||
| 25 | root = 'directory-' + uuid.uuid4().hex[:12] | ||
| 26 | script = f'''from pathlib import Path | ||
| 27 | import json | ||
| 28 | root = Path('/srv/agent') / {root!r} | ||
| 29 | root.mkdir() | ||
| 30 | (root / 'empty').mkdir() | ||
| 31 | (root / 'nested š¦').mkdir() | ||
| 32 | (root / 'denied').mkdir() | ||
| 33 | (root / 'file.one').write_bytes(b'fixture') | ||
| 34 | for i in range(4000): | ||
| 35 | name = f'Section {{i:04d}} š¦ eĢ ' + 'x' * 80 + '.one' | ||
| 36 | with (root / name).open('wb') as file: file.truncate(i * 12345) | ||
| 37 | entries = [dict(name=p.name, directory=p.is_dir(), size=p.stat().st_size) for p in root.iterdir()] | ||
| 38 | (root / 'denied').chmod(0) | ||
| 39 | print(json.dumps(dict(path=root.name, entries=entries), ensure_ascii=False)) | ||
| 40 | ''' | ||
| 41 | fixture = subprocess.run(ssh_argv(args.vm, 'python3 -'), input=script, text=True, | ||
| 42 | capture_output=True, check=True, timeout=60) | ||
| 43 | (output / 'oracle.json').write_text(fixture.stdout) | ||
| 44 | cases = [('complete', {})] | ||
| 45 | cases += [(f'{direction}-{occurrence}', dict(cut=14, direction=direction, occurrence=occurrence)) | ||
| 46 | for direction in ('request', 'response') for occurrence in (1, 2, 10)] | ||
| 47 | cases.append(('close', dict(cut=6, direction='response'))) | ||
| 48 | for name, control in cases: | ||
| 49 | with socket.socket() as reservation: | ||
| 50 | reservation.bind(('127.0.0.1', 0)) | ||
| 51 | port = reservation.getsockname()[1] | ||
| 52 | control_path = output / f'{name}-control.json' | ||
| 53 | control_path.write_text(json.dumps(control)) | ||
| 54 | trace = output / f'{name}-trace.jsonl' | ||
| 55 | with trace.open('w') as log: | ||
| 56 | proxy = subprocess.Popen([sys.executable, str(Path(__file__).with_name('smb-proxy.py')), | ||
| 57 | str(control_path), '--port', str(port), '--server', '127.0.0.1', | ||
| 58 | '--server-port', str(config['samba_port'])], stdout=log, stderr=log) | ||
| 59 | try: | ||
| 60 | deadline = time.monotonic() + 5 | ||
| 61 | while True: | ||
| 62 | records = [json.loads(line) for line in trace.read_text().splitlines()] | ||
| 63 | if any('listening' in row for row in records) and any('control' in row for row in records): | ||
| 64 | break | ||
| 65 | if proxy.poll() is not None or time.monotonic() > deadline: | ||
| 66 | raise RuntimeError('Directory test proxy did not start') | ||
| 67 | time.sleep(.05) | ||
| 68 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{port}', | ||
| 69 | ONESTORE_SMB_DIRECTORY=root, ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) | ||
| 70 | test = 'tests::live_directory_interruption' if control else 'tests::live_directory' | ||
| 71 | with (output / f'{name}.log').open('w') as result: | ||
| 72 | subprocess.run(['cargo', 'test', '-p', 'onestore-smb', test, '--', '--ignored', '--exact'], | ||
| 73 | env=env, stdout=result, stderr=result, check=True, timeout=120) | ||
| 74 | finally: | ||
| 75 | proxy.terminate() | ||
| 76 | proxy.wait(timeout=5) | ||
| 77 | records = [json.loads(line) for line in trace.read_text().splitlines()] | ||
| 78 | if control: | ||
| 79 | assert sum('cut' in row for row in records) == 1 | ||
| 80 | else: | ||
| 81 | assert sum(row.get('command') == 14 and row.get('status') == '0x0' for row in records) > 10 | ||
| 82 | assert any(row.get('command') == 14 and row.get('status') == '0x80000006' for row in records) | ||
| 83 | print(f'{name}: passed', flush=True) | ||
| 84 | env = dict(os.environ, ONESTORE_SMB_LAB=f'127.0.0.1:{config["samba_port"]}', | ||
| 85 | ONESTORE_SMB_DIRECTORY_ORACLE=str(output / 'oracle.json')) | ||
| 86 | with (output / 'reconnected.log').open('w') as result: | ||
| 87 | subprocess.run(['cargo', 'test', '-p', 'onestore-smb', 'tests::live_directory', '--', '--ignored', '--exact'], | ||
| 88 | env=env, stdout=result, stderr=result, check=True, timeout=120) | ||
| 89 | print('reconnected: passed', flush=True) | ||
| 90 | |||
| 91 | |||
| 92 | if __name__ == '__main__': | ||
| 93 | main() | ||