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
3838client. Reconnect for subsequent operations, and reconcile an `Unknown` edit
3939before 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
4156Device and simulator builds link for iOS. Native acceptance uses disposable
4257OneNote 2010 clients and Samba; it does not establish on-device execution or
4358physical 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::{
2525use std::{io, ops::Range, sync::Mutex, time::Duration};
2626use tokio::runtime::{Handle, Runtime};
2727
28mod directory;
29pub use directory::DirectoryEntry;
30
2831#[derive(Default)]
2932pub struct Credentials<'a> {
3033 pub username: &'a str,
......@@ -153,6 +156,8 @@ impl Client {
153156 0xc0000043 | 0xc0000054 | 0xc0000055 => io::ErrorKind::WouldBlock,
154157 0xc0000011 => io::ErrorKind::UnexpectedEof,
155158 0xc0000034 | 0xc000003a => io::ErrorKind::NotFound,
159 0xc0000022 => io::ErrorKind::PermissionDenied,
160 0xc0000103 => io::ErrorKind::NotADirectory,
156161 _ => io::ErrorKind::Other,
157162 };
158163 return Err(io::Error::new(
crates/onestore-smb/src/tests.rs+81
......@@ -10,6 +10,87 @@ use std::{
1010
1111mod 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
1394fn client() -> Client {
1495 Client::connect(
1596 &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()