authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 17:04:43-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-07 17:04:43-07:00
log2c75dd41e5584f3d01c9253e50bdebec6623740f
treed36e782b95ee59ac59dbece4b3fd347754bbd33e
parentd7d1c96c52f172bdfa45f44531559f14750e55f9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: enumerate bounded SMB directories without partial success

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-astra

5 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
38client. Reconnect for subsequent operations, and reconcile an `Unknown` edit38client. Reconnect for subsequent operations, and reconcile an `Unknown` edit
39before retrying it. The transport does not automatically replay requests.39before retrying it. The transport does not automatically replay requests.
4040
41`Client::read_dir(path, entry_limit)` enumerates a directory, including the share
42root with an empty path. It follows every response page and returns no partial
43list on interruption, entry-limit overflow or close failure. Entries retain exact
44Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse
45flags. Concurrent directory changes are not an atomic snapshot; repeated names
46are rejected with `ResourceBusy`. Notebook identities come from the files, not
47directory names or sizes. Missing paths, denied access and non-directory paths
48have distinct I/O error kinds.
49
50`python3 tools/test_smb_directory.py VM OUTPUT` checks a caller-owned disposable
51Linux lab VM against its filesystem listing and interrupts directory requests,
52responses and close. It creates synthetic files in that VM; the caller retains
53responsibility for VM teardown. The parser also has bounded-record/truncation
54tests independent of the server.
55
41Device and simulator builds link for iOS. Native acceptance uses disposable56Device and simulator builds link for iOS. Native acceptance uses disposable
42OneNote 2010 clients and Samba; it does not establish on-device execution or57OneNote 2010 clients and Samba; it does not establish on-device execution or
43physical power-loss durability.58physical power-loss durability.
crates/onestore-smb/src/directory.rs created+219
...@@ -0,0 +1,219 @@
1use super::*;
2use smb2::msg::query_directory::{
3 FileInformationClass, QueryDirectoryFlags, QueryDirectoryRequest, QueryDirectoryResponse,
4};
5use std::collections::BTreeMap;
6
7/// Observed directory metadata, not a stable notebook identity or a file snapshot.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub 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
16impl 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
104fn 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)]
148mod 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::{
25use std::{io, ops::Range, sync::Mutex, time::Duration};25use std::{io, ops::Range, sync::Mutex, time::Duration};
26use tokio::runtime::{Handle, Runtime};26use tokio::runtime::{Handle, Runtime};
2727
28mod directory;
29pub use directory::DirectoryEntry;
30
28#[derive(Default)]31#[derive(Default)]
29pub struct Credentials<'a> {32pub 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::{
1010
11mod faults;11mod faults;
1212
13#[test]
14#[ignore = "requires an owned Samba directory and ONESTORE_SMB_DIRECTORY_ORACLE from its local filesystem"]
15fn 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"]
82fn 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
13fn client() -> Client {94fn 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."""
3import argparse
4import json
5import os
6from pathlib import Path
7import socket
8import subprocess
9import sys
10import time
11import uuid
12
13sys.path.insert(0, str(Path(__file__).resolve().parent / 'w7'))
14from linux_vm import load_instance, ssh_argv
15
16
17def 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
27import json
28root = Path('/srv/agent') / {root!r}
29root.mkdir()
30(root / 'empty').mkdir()
31(root / 'nested šŸ¦€').mkdir()
32(root / 'denied').mkdir()
33(root / 'file.one').write_bytes(b'fixture')
34for i in range(4000):
35 name = f'Section {{i:04d}} šŸ¦€ é ' + 'x' * 80 + '.one'
36 with (root / name).open('wb') as file: file.truncate(i * 12345)
37entries = [dict(name=p.name, directory=p.is_dir(), size=p.stat().st_size) for p in root.iterdir()]
38(root / 'denied').chmod(0)
39print(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
92if __name__ == '__main__':
93 main()