fix(doc-api): preserve XDATA and XRecord integrity

This commit is contained in:
Hakan Seven committed 2026-09-10 18:00:07 +03:00
1 parent acba9f87bd
commit 4de2792798
9 files changed
+367 -108

No files matched your search

+9 -6
View File
@@ -411,12 +411,15 @@ fn apply_op_inner<B: DocApiBackend>(b: &mut B, op: Operation) -> ApiResult<Recei
OpOutcome::Updated(*id)
}
Operation::SetXDataMany(records) => {
// Pre-validate every entity id before any mutation.
for (id, app, _) in records {
require_exists(b, *id, name)?;
if app.is_empty() {
return Err(ApiError::validation(name, "empty application_name"));
}
// A later locked entity must not leave earlier records changed.
for (i, (id, _, _)) in records.iter().enumerate() {
b.can_modify(*id).map_err(|e| match e {
ApiError::UnknownId(_) => stale_index(name, i, *id),
ApiError::Unsupported(reason) => {
ApiError::validation(name, format!("index {i}: {reason}"))
}
other => other,
})?;
}
b.push_undo(name);
for (id, application_name, record) in records {
+23 -17
View File
@@ -386,6 +386,12 @@ macro_rules! handle {
self.id
}
}
};
}
macro_rules! entity_handle {
($name:ident) => {
handle!($name);
impl $name {
pub fn bounds(&self) -> ApiResult<Aabb> {
match self.session.one_query(Query::GetBounds { id: self.id })? {
@@ -427,20 +433,20 @@ macro_rules! handle {
};
}
handle!(Entity);
handle!(Solid);
handle!(Line);
handle!(Circle);
handle!(Polyline);
handle!(Point);
handle!(ArcCurve);
handle!(Ellipse);
handle!(Spline);
handle!(Ray);
handle!(XLine);
handle!(Text);
handle!(MText);
handle!(Dimension);
entity_handle!(Entity);
entity_handle!(Solid);
entity_handle!(Line);
entity_handle!(Circle);
entity_handle!(Polyline);
entity_handle!(Point);
entity_handle!(ArcCurve);
entity_handle!(Ellipse);
entity_handle!(Spline);
entity_handle!(Ray);
entity_handle!(XLine);
entity_handle!(Text);
entity_handle!(MText);
entity_handle!(Dimension);
handle!(XRecord);
impl XRecord {
@@ -1308,8 +1314,8 @@ impl EntityCollection {
Ok(())
}
/// Create a standalone `XRECORD` object (returns an `Entity` handle).
pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult<Entity> {
/// Create a standalone `XRECORD` object.
pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult<XRecord> {
let receipt = self
.session
.apply_op(Operation::CreateXRecord(spec.clone()))?;
@@ -1317,7 +1323,7 @@ impl EntityCollection {
.outcome
.and_then(|o| o.new_id())
.ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?;
Ok(Entity::new(self.session.clone(), id))
Ok(XRecord::new(self.session.clone(), id))
}
/// Lookup an `XRECORD` object by id and return a typed accessor handle.
+4 -5
View File
@@ -198,14 +198,12 @@ Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped ve
### Constructors (`doc.entities()`)
- **create_xrecord**(spec: XRecordSpec) -> `Entity` — `CreateXRecord`
- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord`
### Methods
- **payload**() -> `XRecordSpec` — query `GetXRecord`
- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord`
- **xdata**(application_name: &str) -> `Option<XDataRecord>` — query `GetXData`
- **set_xdata**(application_name: &str, record: Option<XDataRecord>) -> `()` — op `SetXData`
## `Layer` (acadrust `-`, collection `layers`)
@@ -234,8 +232,9 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`,
- **`set_layer(layer)`** — move the entity to an existing layer (one undo step;
blocked if the entity or target layer is locked).
`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed
downcast when `kind == "Solid3D"`).
`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed
downcast when `kind == "Solid3D"`), and XDATA access through
`xdata(application_name)` / `set_xdata(application_name, record)`.
## Layer table
+1 -37
View File
@@ -1140,7 +1140,7 @@
"fixed": null,
"name": "create_xrecord",
"op": "CreateXRecord",
"returns": "Entity"
"returns": "XRecord"
}
],
"handle": "XRecord",
@@ -1171,42 +1171,6 @@
"query": null,
"read_only": false,
"returns": "()"
},
{
"args": [
{
"name": "application_name",
"ty": "&str"
}
],
"bulk": false,
"fixed": null,
"kind": "query",
"name": "xdata",
"op": null,
"query": "GetXData",
"read_only": true,
"returns": "Option<XDataRecord>"
},
{
"args": [
{
"name": "application_name",
"ty": "&str"
},
{
"name": "record",
"ty": "Option<XDataRecord>"
}
],
"bulk": false,
"fixed": null,
"kind": "op",
"name": "set_xdata",
"op": "SetXData",
"query": null,
"read_only": false,
"returns": "()"
}
],
"name": "XRecord"
+112 -5
View File
@@ -21,6 +21,88 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> {
}
}
fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> {
if valid {
Ok(())
} else {
Err(ApiError::validation(op, reason))
}
}
fn valid_application_name(name: &str) -> bool {
!name.trim().is_empty() && !name.contains('\0')
}
fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool {
valid_application_name(app)
&& record.is_none_or(|record| {
record.application_name == app
&& record.values.iter().all(|value| match value {
XDataValue::String(value)
| XDataValue::ControlString(value)
| XDataValue::LayerName(value) => !value.contains('\0'),
XDataValue::Point3D(value)
| XDataValue::Position3D(value)
| XDataValue::Displacement3D(value)
| XDataValue::Direction3D(value) => finite(value),
XDataValue::Real(value)
| XDataValue::Distance(value)
| XDataValue::ScaleFactor(value) => value.is_finite(),
XDataValue::BinaryData(_)
| XDataValue::Handle(_)
| XDataValue::Integer16(_)
| XDataValue::Integer32(_) => true,
})
})
}
fn valid_xrecord(spec: &XRecordSpec) -> bool {
!spec.name.trim().is_empty()
&& !spec.name.contains('\0')
&& spec.entries.len() <= BULK_ITEM_CAP
&& spec.entries.iter().all(|entry| {
let code = entry.code;
match &entry.value {
XRecordValue::String(value) => {
matches!(
code,
0..=4
| 6..=9
| 100..=102
| 300..=309
| 410..=419
| 430..=439
| 470..=479
| 999
| 1000..=1003
) && !value.contains('\0')
&& value.encode_utf16().count() <= u16::MAX as usize
}
XRecordValue::Point3D(value) => {
matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069)
&& finite(value)
}
XRecordValue::Double(value) => {
matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042)
&& value.is_finite()
}
XRecordValue::Int16(_) => {
matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070)
}
XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071),
XRecordValue::Int64(_) => matches!(code, 150..=169),
XRecordValue::Byte(_) => matches!(code, 280..=289),
XRecordValue::Bool(_) => matches!(code, 290..=299),
XRecordValue::Handle(_) => {
code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005)
}
XRecordValue::Chunk(value) => {
matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize
}
}
})
}
pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> {
use Curve2Spec::*;
let valid = match spec {
@@ -116,11 +198,36 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> {
}
CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(),
CreateMText(s) => finite(&s.insertion_point) && positive(s.height),
SetXDataMany(records) => records.iter().all(|(id, app, record)| {
let app_ok = !app.is_empty();
let record_ok = record.as_ref().map_or(true, |r| !r.application_name.is_empty() && !r.application_name.contains('\0'));
*id != ObjectId::NULL && app_ok && record_ok
}),
SetXData {
id,
application_name,
record,
} => {
return check_payload(
*id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()),
"SetXData",
"invalid XDATA record",
)
}
SetXDataMany(records) => {
return check_payload(
records.iter().all(|(id, app, record)| {
*id != ObjectId::NULL && valid_xdata_record(app, record.as_ref())
}),
"SetXDataMany",
"invalid XDATA record",
)
}
CreateXRecord(spec) => {
return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload")
}
SetXRecord { id, spec } => {
return check_payload(
*id != ObjectId::NULL && valid_xrecord(spec),
"SetXRecord",
"invalid XRecord payload",
)
}
CreateHatch(s) => {
(3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p))
}