bacnet-server 0.15.0

BACnet server: APDU dispatch and service handlers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
use super::*;
use bacnet_objects::file::FileWriteStart;
use bacnet_objects::traits::BACnetObject;
use bacnet_types::enums::FileAccessMethod as ObjectFileAccessMethod;
use bacnet_types::error::RemoteError;

/// Refuse a request whose access method does not match the File object's
/// declared File_Access_Method, or whose declared method cannot be read
/// back as the Clause 21 `BACnetFileAccessMethod` production.
///
/// Clauses 14.1 and 14.2 require SERVICES / INVALID_FILE_ACCESS_METHOD for
/// an access-method mismatch, and Clause 18 defines the code for exactly
/// that case: a read or write request naming the stream or record method
/// when the File object declares the other one. Reading fails
/// closed: a missing, undecodable, or out-of-production property value is
/// treated as a mismatch rather than defaulting to stream or record access.
fn invalid_file_access_method() -> Error {
    Error::protocol(ErrorClass::SERVICES, ErrorCode::INVALID_FILE_ACCESS_METHOD)
}

/// Refuse a request whose 'File Identifier' names a non-File object type.
///
/// The Clause 14.1.4.1 and 14.2.4.1 error tables assign SERVICES /
/// INCONSISTENT_OBJECT_TYPE to identifiers of non-File objects, and
/// Clause 18 gives an AtomicReadFile request for a non-File object as the
/// code's example. The type is a property of the parameter alone, so it is
/// classified before the object lookup; the standard does not sequence this
/// check against the missing-File-object check, so an absent non-File
/// identifier gets this error rather than OBJECT / UNKNOWN_OBJECT.
fn inconsistent_object_type() -> Error {
    Error::protocol(ErrorClass::SERVICES, ErrorCode::INCONSISTENT_OBJECT_TYPE)
}

fn validate_file_access_method(
    object: &dyn BACnetObject,
    expected: ObjectFileAccessMethod,
) -> Result<(), Error> {
    let actual = match object.read_property(PropertyIdentifier::FILE_ACCESS_METHOD, None) {
        Ok(PropertyValue::Enumerated(raw)) => raw,
        _ => return Err(invalid_file_access_method()),
    };
    if actual != expected.to_raw() {
        return Err(invalid_file_access_method());
    }
    Ok(())
}

/// Refuse a request whose start position or record is not valid for the
/// file.
///
/// The Clause 14.1 Service Procedure returns an error when 'File Start
/// Position' or 'File Start Record' is negative or beyond the file's end;
/// Clause 18 pairs both parameters with
/// INVALID_FILE_START_POSITION. Clause 14.2 defines only -1 as a negative
/// write start (append), so any other negative value gets the same error.
fn invalid_file_start_position() -> Error {
    Error::protocol(ErrorClass::SERVICES, ErrorCode::INVALID_FILE_START_POSITION)
}

/// Refuse access to a File object the handler cannot safely use.
///
/// Clause 18 uses FILE_ACCESS_DENIED for locked or inaccessible files,
/// and Clause 14.2.4.1 also uses it for attempted writes to read-only files.
/// Both handlers report a File-typed object whose
/// `file_storage_internal` hook returns `None` this way rather than reading
/// it as empty, and the write handler also reports a `Read_Only` that is
/// TRUE, unreadable, or not a BOOLEAN: like the access-method gate, it
/// fails closed instead of assuming the file is writable.
fn file_access_denied() -> Error {
    Error::protocol(ErrorClass::SERVICES, ErrorCode::FILE_ACCESS_DENIED)
}

fn unknown_object() -> Error {
    Error::protocol(ErrorClass::OBJECT, ErrorCode::UNKNOWN_OBJECT)
}

/// Resolved write positions travel in the ACK as an INTEGER; the
/// `FileStorage` contract keeps them within that range, so a value that
/// does not fit is an implementation fault, not a client error.
fn ack_position(actual: u64) -> Result<i32, Error> {
    i32::try_from(actual).map_err(|_| {
        Error::Remote(RemoteError::Error {
            class: ErrorClass::DEVICE,
            code: ErrorCode::INTERNAL_ERROR,
        })
    })
}

/// Handle an AtomicReadFile request.
///
/// The dispatcher holds the object database's read guard for the whole
/// handler, so no AtomicWriteFile can interleave the read (Clause 14
/// atomicity); concurrent AtomicReadFile handlers are admitted and see the
/// same unmodified contents.
pub fn handle_atomic_read_file(
    db: &ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
) -> Result<(), Error> {
    read_file(db, service_data, buf, None, |_, _| {}).map_err(|failure| match failure {
        AtomicReadFileFailure::Service(error) => error,
        AtomicReadFileFailure::Budget(_) => unreachable!("unconfigured read has no budget"),
    })
}

/// Keep local budget refusals distinct from pre-existing service/storage errors.
#[derive(Debug)]
pub(crate) enum AtomicReadFileFailure {
    Service(Error),
    Budget(bacnet_types::enums::AbortReason),
}

impl From<Error> for AtomicReadFileFailure {
    fn from(error: Error) -> Self {
        Self::Service(error)
    }
}

/// Apply local request-count and complete service-ACK budgets, preserving the
/// legacy handler's validation and storage-error precedence for admitted reads.
#[cfg(test)]
pub(crate) fn handle_atomic_read_file_budgeted(
    db: &ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: crate::server::AtomicReadFileBudget,
) -> Result<(), AtomicReadFileFailure> {
    handle_atomic_read_file_observed(db, service_data, buf, budget, |_, _| {})
}

/// Observe one decoded file outcome without rereading storage. Configured
/// budget failures are not service outcomes; no provisional intent escapes them.
pub(crate) fn handle_atomic_read_file_observed(
    db: &ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: crate::server::AtomicReadFileBudget,
    completed: impl FnOnce(ObjectIdentifier, &Result<(), Error>),
) -> Result<(), AtomicReadFileFailure> {
    read_file(db, service_data, buf, Some(budget), completed)
}

fn read_file(
    db: &ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: Option<crate::server::AtomicReadFileBudget>,
    completed: impl FnOnce(ObjectIdentifier, &Result<(), Error>),
) -> Result<(), AtomicReadFileFailure> {
    let request = bacnet_services::file::AtomicReadFileRequest::decode(service_data)
        .map_err(Error::into_request_reject)?;
    let target = request.file_identifier;
    let result = match execute_read_file(db, request, buf, budget) {
        Ok(()) => Ok(()),
        Err(AtomicReadFileFailure::Service(error)) => Err(error),
        Err(failure @ AtomicReadFileFailure::Budget(_)) => return Err(failure),
    };
    completed(target, &result);
    result.map_err(AtomicReadFileFailure::Service)
}

fn execute_read_file(
    db: &ObjectDatabase,
    request: bacnet_services::file::AtomicReadFileRequest,
    buf: &mut BytesMut,
    budget: Option<crate::server::AtomicReadFileBudget>,
) -> Result<(), AtomicReadFileFailure> {
    use bacnet_services::common::MAX_DECODED_ITEMS;
    use bacnet_services::file::{AtomicReadFileAck, FileAccessMethod, FileReadAckMethod};

    if request.file_identifier.object_type() != ObjectType::FILE {
        return Err(inconsistent_object_type().into());
    }

    let object = db
        .get(&request.file_identifier)
        .ok_or_else(unknown_object)?;

    // Clause 14.1 Service Procedure, first step: reject an inaccessible
    // File object before its properties are
    // consulted; an object without storage is that case.
    if object.file_storage_internal().is_none() {
        return Err(file_access_denied().into());
    }

    // Clause 14.1: refuse a mismatched access method before any file read
    // or ACK encoding. The request CHOICE maps semantically — Stream means
    // STREAM_ACCESS, Record means RECORD_ACCESS — never by CHOICE tag
    // number (stream is CHOICE [0] but enumeration value 1).
    let expected = match &request.access {
        FileAccessMethod::Stream { .. } => ObjectFileAccessMethod::STREAM_ACCESS,
        FileAccessMethod::Record { .. } => ObjectFileAccessMethod::RECORD_ACCESS,
    };
    validate_file_access_method(object, expected)?;

    let storage = object
        .file_storage_internal()
        .ok_or_else(file_access_denied)?;

    match request.access {
        FileAccessMethod::Stream {
            file_start_position,
            requested_octet_count,
        } => {
            let start =
                u64::try_from(file_start_position).map_err(|_| invalid_file_start_position())?;
            if budget.is_some_and(|b| {
                u64::from(requested_octet_count) > b.max_requested_stream_octets as u64
            }) {
                return Err(AtomicReadFileFailure::Budget(
                    bacnet_types::enums::AbortReason::OUT_OF_RESOURCES,
                ));
            }
            let read = storage.read_stream(start, u64::from(requested_octet_count))?;
            let ack = AtomicReadFileAck {
                end_of_file: read.end_of_file,
                access: FileReadAckMethod::Stream {
                    file_start_position,
                    file_data: read.data,
                },
            };
            encode_read_ack(&ack, buf, budget)?;
            Ok(())
        }
        FileAccessMethod::Record {
            file_start_record,
            requested_record_count,
        } => {
            let start =
                u64::try_from(file_start_record).map_err(|_| invalid_file_start_position())?;
            if budget
                .is_some_and(|b| u64::from(requested_record_count) > b.max_requested_records as u64)
            {
                return Err(AtomicReadFileFailure::Budget(
                    bacnet_types::enums::AbortReason::OUT_OF_RESOURCES,
                ));
            }
            // The workspace's AtomicReadFile-ACK decoder accepts at most
            // MAX_DECODED_ITEMS records in one SEQUENCE OF, so one ACK never
            // carries more. A client sees 'Returned Record Count' below its
            // request and, while records remain, End Of File FALSE, so it
            // continues from start + returned. Clause 14.1's Service
            // Procedure short-reads only when fewer records remain; this
            // window is a second, local reason.
            let count = u64::from(requested_record_count).min(MAX_DECODED_ITEMS as u64);
            let read = storage.read_records(start, count)?;
            let ack = AtomicReadFileAck {
                end_of_file: read.end_of_file,
                access: FileReadAckMethod::Record {
                    file_start_record,
                    returned_record_count: read.records.len() as u32,
                    file_record_data: read.records,
                },
            };
            encode_read_ack(&ack, buf, budget)?;
            Ok(())
        }
    }
}

fn encode_read_ack(
    ack: &bacnet_services::file::AtomicReadFileAck,
    buf: &mut BytesMut,
    budget: Option<crate::server::AtomicReadFileBudget>,
) -> Result<(), AtomicReadFileFailure> {
    if budget.is_some_and(|b| ack.encoded_len_bounded(b.max_service_ack_bytes).is_none()) {
        return Err(AtomicReadFileFailure::Budget(
            bacnet_types::enums::AbortReason::BUFFER_OVERFLOW,
        ));
    }
    // All budget failures precede encoding: no payload copy or caller mutation
    // is needed to size the owned storage result.
    ack.encode(buf);
    Ok(())
}

/// Handle an AtomicWriteFile request.
///
/// The dispatcher holds the object database's write guard for the whole
/// handler, so the gates, the write, and the ACK are one atomic operation
/// per Clause 14. Pre-write refusals do not invoke a storage write or modify
/// caller output (metadata/hook side effects are excluded); a storage that breaks the
/// [`FileStorage`](bacnet_objects::file::FileStorage) position contract
/// can leave the object mutated and still draw DEVICE / INTERNAL_ERROR
/// from the ACK conversion.
pub fn handle_atomic_write_file(
    db: &mut ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
) -> Result<(), Error> {
    write_file(db, service_data, buf, None, |_, _, _| {}).map_err(|failure| match failure {
        AtomicWriteFileFailure::Service(error) => error,
        AtomicWriteFileFailure::Budget => unreachable!("unconfigured write has no budget"),
    })
}

/// A local admission refusal is not an opaque storage `Error::abort`.
#[derive(Debug)]
pub(crate) enum AtomicWriteFileFailure {
    Service(Error),
    Budget,
}

impl From<Error> for AtomicWriteFileFailure {
    fn from(error: Error) -> Self {
        Self::Service(error)
    }
}

#[cfg(test)]
pub(crate) fn handle_atomic_write_file_budgeted(
    db: &mut ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: crate::server::AtomicWriteFileBudget,
) -> Result<(), AtomicWriteFileFailure> {
    handle_atomic_write_file_observed(db, service_data, buf, budget, |_, _, _| {})
}

/// Observe non-budget outcomes accepted by the service decoder, under the
/// caller's database guard. The decoder refuses a request with octets after
/// its last member, so such a request writes nothing and never reaches the
/// hook, like any other decoder rejection or configured pre-execution overload.
pub(crate) fn handle_atomic_write_file_observed(
    db: &mut ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: crate::server::AtomicWriteFileBudget,
    completed: impl FnOnce(&mut ObjectDatabase, ObjectIdentifier, &Result<(), Error>),
) -> Result<(), AtomicWriteFileFailure> {
    write_file(db, service_data, buf, Some(budget), completed)
}

fn write_file(
    db: &mut ObjectDatabase,
    service_data: &[u8],
    buf: &mut BytesMut,
    budget: Option<crate::server::AtomicWriteFileBudget>,
    completed: impl FnOnce(&mut ObjectDatabase, ObjectIdentifier, &Result<(), Error>),
) -> Result<(), AtomicWriteFileFailure> {
    use bacnet_services::file::{AtomicWriteFileRequest, FileWriteAccessMethod};

    let request =
        AtomicWriteFileRequest::decode(service_data).map_err(Error::into_request_reject)?;

    // Wire decoding enforces record cardinality. Keep this cross-check so a
    // future typed or internal request path cannot bypass the same pre-mutation
    // refusal.
    if let FileWriteAccessMethod::Record {
        record_count,
        file_record_data,
        ..
    } = &request.access
        && *record_count as usize != file_record_data.len()
    {
        return Err(Error::reject(RejectReason::MISSING_REQUIRED_PARAMETER).into());
    }

    let target = request.file_identifier;
    let result = match execute_write_file(db, request, buf, budget) {
        Ok(()) => Ok(()),
        Err(AtomicWriteFileFailure::Service(error)) => Err(error),
        Err(AtomicWriteFileFailure::Budget) => return Err(AtomicWriteFileFailure::Budget),
    };
    completed(db, target, &result);
    result.map_err(AtomicWriteFileFailure::Service)
}

fn execute_write_file(
    db: &mut ObjectDatabase,
    request: bacnet_services::file::AtomicWriteFileRequest,
    buf: &mut BytesMut,
    budget: Option<crate::server::AtomicWriteFileBudget>,
) -> Result<(), AtomicWriteFileFailure> {
    use bacnet_services::file::{AtomicWriteFileAck, FileWriteAccessMethod, FileWriteAckMethod};

    if request.file_identifier.object_type() != ObjectType::FILE {
        return Err(inconsistent_object_type().into());
    }

    let object = db
        .get_mut(&request.file_identifier)
        .ok_or_else(unknown_object)?;

    // Clause 14.2 Service Procedure, first step: reject an inaccessible
    // File object before its properties are
    // consulted; an object without storage is that case.
    if object.file_storage_internal().is_none() {
        return Err(file_access_denied().into());
    }

    // Clause 14.2.4.1 rejects writes to read-only files. Reading the property
    // fails closed, as the access-method gate below does: a missing,
    // undecodable, or non-BOOLEAN Read_Only is treated as read-only rather
    // than as permission to write.
    match object.read_property(PropertyIdentifier::READ_ONLY, None) {
        Ok(PropertyValue::Boolean(false)) => {}
        _ => return Err(file_access_denied().into()),
    }

    // Clause 14.2: refuse a mismatched access method before any mutation or
    // ACK encoding, preserving the existing READ_ONLY precedence. The
    // request CHOICE maps semantically — Stream means STREAM_ACCESS, Record
    // means RECORD_ACCESS — never by CHOICE tag number.
    let expected = match &request.access {
        FileWriteAccessMethod::Stream { .. } => ObjectFileAccessMethod::STREAM_ACCESS,
        FileWriteAccessMethod::Record { .. } => ObjectFileAccessMethod::RECORD_ACCESS,
    };
    validate_file_access_method(object, expected)?;

    let storage = object
        .file_storage_internal_mut()
        .ok_or_else(file_access_denied)?;

    match request.access {
        FileWriteAccessMethod::Stream {
            file_start_position,
            file_data,
        } => {
            let start = write_start(file_start_position)?;
            if budget.is_some_and(|b| file_data.len() > b.max_stream_payload_octets) {
                return Err(AtomicWriteFileFailure::Budget);
            }
            let actual = storage.write_stream(start, &file_data)?;
            let ack = AtomicWriteFileAck {
                access: FileWriteAckMethod::Stream {
                    file_start_position: ack_position(actual)?,
                },
            };
            ack.encode(buf);
            Ok(())
        }
        FileWriteAccessMethod::Record {
            file_start_record,
            file_record_data,
            ..
        } => {
            let start = write_start(file_start_record)?;
            if budget.is_some_and(|b| {
                file_record_data.len() > b.max_records
                    || !record_payload_fits(
                        file_record_data.iter().map(Vec::len),
                        b.max_record_payload_bytes,
                    )
            }) {
                return Err(AtomicWriteFileFailure::Budget);
            }
            let actual = storage.write_records(start, &file_record_data)?;
            let ack = AtomicWriteFileAck {
                access: FileWriteAckMethod::Record {
                    file_start_record: ack_position(actual)?,
                },
            };
            ack.encode(buf);
            Ok(())
        }
    }
}

/// Map a request's 'File Start Position' / 'File Start Record' to a write
/// start: -1 is the Clauses 14.2.2.2 / 14.2.2.3 append sentinel, other
/// negatives are invalid, and the ACK later carries the position actually
/// written.
fn write_start(requested: i32) -> Result<FileWriteStart, Error> {
    match requested {
        -1 => Ok(FileWriteStart::Append),
        position => u64::try_from(position)
            .map(FileWriteStart::At)
            .map_err(|_| invalid_file_start_position()),
    }
}

/// Checked sum, stopping at the first excess without examining later lengths.
fn record_payload_fits(lengths: impl IntoIterator<Item = usize>, cap: usize) -> bool {
    let mut total = 0usize;
    for len in lengths {
        let Some(next) = total.checked_add(len).filter(|&next| next <= cap) else {
            return false;
        };
        total = next;
    }
    true
}

#[cfg(test)]
mod write_budget_arithmetic {
    use super::record_payload_fits;

    #[test]
    fn atomic_write_file_checked_sum_and_early_stop() {
        assert!(record_payload_fits([0, usize::MAX], usize::MAX));
        assert!(!record_payload_fits([usize::MAX, 1], usize::MAX));
        assert!(record_payload_fits([2, 3, 0], 5));
        assert!(!record_payload_fits([2, 3], 4));
        assert!(!record_payload_fits(
            std::iter::once(6).chain(std::iter::from_fn(|| panic!("past excess"))),
            5
        ));
    }
}