Skip to content

Commit 5e2a6a6

Browse files
authored
[3/n] [wicketd-commission-types] reject unknown fields in UserSpecifiedPortConfig (#10808)
`serde(untagged)` is a bit fragile here. I'm not sure if we're going to switch this to internal tagging in the future, but this code can be removed if we make that change. Depends on: * #10806 * #10807
1 parent a19e2e8 commit 5e2a6a6

4 files changed

Lines changed: 289 additions & 17 deletions

File tree

openapi/wicketd.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7978,12 +7978,20 @@
79787978
}
79797979
},
79807980
"UserSpecifiedPortConfig": {
7981+
"description": "A user-specified port configuration.",
79817982
"anyOf": [
79827983
{
7983-
"$ref": "#/components/schemas/ManualPortConfig"
7984+
"description": "A manually-configured port.",
7985+
"allOf": [
7986+
{
7987+
"$ref": "#/components/schemas/ManualPortConfig"
7988+
}
7989+
]
79847990
},
79857991
{
7986-
"type": "object"
7992+
"description": "A port configured automatically via DDM.",
7993+
"type": "object",
7994+
"additionalProperties": false
79877995
}
79887996
]
79897997
},

wicket/src/cli/rack_setup/config_toml.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,13 @@ fn populate_uplink_table(cfg: &UserSpecifiedPortConfig) -> Table {
349349
// This style ensures that if a new field is added, this fails loudly.
350350
let manual_port_config = match cfg {
351351
UserSpecifiedPortConfig::Manual(manual) => manual,
352-
UserSpecifiedPortConfig::DdmAutoPortConfig {} => {
352+
UserSpecifiedPortConfig::DdmAutoPortConfig => {
353+
// A DDM-auto port is encoded as an empty table (the comment is
354+
// operator-facing).
353355
let mut uplink = Table::new();
354-
uplink.insert("type", string_item("ddm_auto_port_config"));
356+
uplink.decor_mut().set_prefix(
357+
"\n# This port is configured automatically via DDM.\n",
358+
);
355359
return uplink;
356360
}
357361
};

wicketd-commission-types/versions/src/impls/rack_setup.rs

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ impl UserSpecifiedRackNetworkConfig {
5353
UserSpecifiedPortConfig::Manual(cfg) => {
5454
Some((SwitchSlot::Switch0, port.as_str(), cfg))
5555
}
56-
UserSpecifiedPortConfig::DdmAutoPortConfig {} => None,
56+
UserSpecifiedPortConfig::DdmAutoPortConfig => None,
5757
});
5858

5959
let iter1 = self.switch1.iter().filter_map(|(port, cfg)| match cfg {
6060
UserSpecifiedPortConfig::Manual(cfg) => {
6161
Some((SwitchSlot::Switch1, port.as_str(), cfg))
6262
}
63-
UserSpecifiedPortConfig::DdmAutoPortConfig {} => None,
63+
UserSpecifiedPortConfig::DdmAutoPortConfig => None,
6464
});
6565

6666
iter0.chain(iter1)
@@ -89,14 +89,14 @@ impl UserSpecifiedPortConfig {
8989
pub fn manual(&self) -> Option<&ManualPortConfig> {
9090
match self {
9191
Self::Manual(cfg) => Some(cfg),
92-
Self::DdmAutoPortConfig {} => None,
92+
Self::DdmAutoPortConfig => None,
9393
}
9494
}
9595

9696
pub fn manual_mut(&mut self) -> Option<&mut ManualPortConfig> {
9797
match self {
9898
Self::Manual(cfg) => Some(cfg),
99-
Self::DdmAutoPortConfig {} => None,
99+
Self::DdmAutoPortConfig => None,
100100
}
101101
}
102102
}
@@ -179,8 +179,9 @@ impl From<UserSpecifiedImportExportPolicy> for ImportExportPolicy {
179179
#[cfg(test)]
180180
mod tests {
181181
use crate::latest::rack_setup::{
182-
UplinkAddress, UserSpecifiedImportExportPolicy,
183-
UserSpecifiedRouterPeerAddr,
182+
LinkFec, LinkSpeed, ManualPortConfig, UplinkAddress,
183+
UserSpecifiedImportExportPolicy, UserSpecifiedPortConfig,
184+
UserSpecifiedRouterPeerAddr, UserSpecifiedUplinkAddressConfig,
184185
};
185186
use crate::v1::rack_setup::uplink_address_serde;
186187
use serde::{Deserialize, Serialize};
@@ -357,4 +358,101 @@ mod tests {
357358
#[serde(with = "uplink_address_serde")]
358359
pub addr: UplinkAddress,
359360
}
361+
362+
#[test]
363+
fn empty_map_deserializes_to_ddm_auto() {
364+
let from_json: UserSpecifiedPortConfig =
365+
serde_json::from_str("{}").unwrap();
366+
assert_eq!(from_json, UserSpecifiedPortConfig::DdmAutoPortConfig);
367+
368+
let from_toml: PortConfigWrapper =
369+
toml::from_str("port = {}\n").unwrap();
370+
assert_eq!(from_toml.port, UserSpecifiedPortConfig::DdmAutoPortConfig);
371+
}
372+
373+
#[test]
374+
fn ddm_auto_serializes_to_empty_map() {
375+
let config = UserSpecifiedPortConfig::DdmAutoPortConfig;
376+
377+
let json = serde_json::to_string(&config).unwrap();
378+
assert_eq!(json, "{}");
379+
let roundtripped: UserSpecifiedPortConfig =
380+
serde_json::from_str(&json).unwrap();
381+
assert_eq!(roundtripped, config);
382+
383+
let wrapper = PortConfigWrapper { port: config.clone() };
384+
let toml_str = toml::to_string(&wrapper).unwrap();
385+
let roundtripped: PortConfigWrapper =
386+
toml::from_str(&toml_str).unwrap();
387+
assert_eq!(roundtripped.port, config);
388+
}
389+
390+
#[test]
391+
fn manual_config_roundtrips() {
392+
let expected = UserSpecifiedPortConfig::Manual(ManualPortConfig {
393+
routes: vec![],
394+
addresses: vec![UserSpecifiedUplinkAddressConfig::without_vlan(
395+
"1.1.1.0/24".parse().unwrap(),
396+
)],
397+
uplink_port_speed: LinkSpeed::Speed40G,
398+
uplink_port_fec: Some(LinkFec::Rs),
399+
autoneg: false,
400+
bgp_peers: vec![],
401+
lldp: None,
402+
tx_eq: None,
403+
});
404+
405+
eprintln!("** testing JSON round-trip");
406+
let json = serde_json::to_string(&expected).unwrap();
407+
eprintln!("serialized JSON: {json}");
408+
let from_json: UserSpecifiedPortConfig =
409+
serde_json::from_str(&json).unwrap();
410+
assert_eq!(from_json, expected);
411+
assert!(from_json.manual().is_some());
412+
413+
eprintln!("** testing TOML deserialization");
414+
let toml_manual = r#"
415+
routes = []
416+
addresses = [{ address = "1.1.1.0/24" }]
417+
uplink_port_speed = "speed40_g"
418+
uplink_port_fec = "rs"
419+
autoneg = false
420+
"#;
421+
let from_toml: UserSpecifiedPortConfig =
422+
toml::from_str(toml_manual).unwrap();
423+
assert_eq!(from_toml, expected);
424+
}
425+
426+
#[test]
427+
fn misspelled_field_names_unknown_field() {
428+
let err =
429+
serde_json::from_str::<UserSpecifiedPortConfig>(r#"{"route": []}"#)
430+
.expect_err("misspelled field should fail to deserialize");
431+
let err = err.to_string();
432+
assert!(
433+
err.contains("unknown field `route`"),
434+
"error should name the unknown field, got: {err}"
435+
);
436+
assert!(
437+
err.contains("expected one of"),
438+
"error should list the expected fields, got: {err}"
439+
);
440+
}
441+
442+
#[test]
443+
fn non_map_input_fails_cleanly() {
444+
let err =
445+
serde_json::from_str::<UserSpecifiedPortConfig>(r#""not-a-map""#)
446+
.expect_err("a string is not a valid port configuration");
447+
let err = err.to_string();
448+
assert!(
449+
err.contains("invalid type: string"),
450+
"error should report an invalid type, got: {err}"
451+
);
452+
}
453+
454+
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
455+
struct PortConfigWrapper {
456+
port: UserSpecifiedPortConfig,
457+
}
360458
}

wicketd-commission-types/versions/src/initial/rack_setup.rs

Lines changed: 169 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,178 @@ pub struct ManualPortConfig {
101101
pub tx_eq: Option<TxEqConfig>,
102102
}
103103

104-
// We use `serde(untagged)` here, since the variants are vastly different.
105-
// This prevents having backwards incompatible changes in the RSS config before
106-
// multirack ships. Once multirack ships, we may wish to use internal tagging,
107-
// but it's not mandatory.
108-
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
109-
#[serde(rename_all = "snake_case", untagged)]
104+
/// A user-specified port configuration.
105+
///
106+
/// An empty map is serialized and deserialized as an auto config.
107+
#[derive(Clone, Debug, PartialEq, Eq)]
110108
#[allow(clippy::large_enum_variant)]
111109
pub enum UserSpecifiedPortConfig {
110+
/// A manually-configured port.
112111
Manual(ManualPortConfig),
113-
DdmAutoPortConfig {},
112+
/// A port configured automatically via DDM.
113+
DdmAutoPortConfig,
114+
}
115+
116+
// Hand-roll the Serialize and Deserialize impls so we don't have to use
117+
// serde(untagged), under which invalid manual configs would silently fall back
118+
// to the auto variant.
119+
//
120+
// We may wish to switch this to internal tagging in the future, but that will
121+
// cause changes to the TOML config as well as the JSON schema.
122+
impl Serialize for UserSpecifiedPortConfig {
123+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
124+
where
125+
S: Serializer,
126+
{
127+
match self {
128+
Self::Manual(cfg) => cfg.serialize(serializer),
129+
Self::DdmAutoPortConfig => {
130+
use serde::ser::SerializeMap;
131+
serializer.serialize_map(Some(0))?.end()
132+
}
133+
}
134+
}
135+
}
136+
137+
impl<'de> Deserialize<'de> for UserSpecifiedPortConfig {
138+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139+
where
140+
D: serde::Deserializer<'de>,
141+
{
142+
struct PortConfigVisitor;
143+
144+
impl<'de> serde::de::Visitor<'de> for PortConfigVisitor {
145+
type Value = UserSpecifiedPortConfig;
146+
147+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
148+
formatter.write_str(
149+
"a map of manual port configuration fields, or an empty \
150+
map for a DDM-automatic port",
151+
)
152+
}
153+
154+
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
155+
where
156+
A: serde::de::MapAccess<'de>,
157+
{
158+
let Some(first_key) = map.next_key::<String>()? else {
159+
return Ok(UserSpecifiedPortConfig::DdmAutoPortConfig);
160+
};
161+
162+
let replay =
163+
ReplayFirstKey { first_key: Some(first_key), inner: map };
164+
let manual = ManualPortConfig::deserialize(
165+
serde::de::value::MapAccessDeserializer::new(replay),
166+
)?;
167+
Ok(UserSpecifiedPortConfig::Manual(manual))
168+
}
169+
}
170+
171+
deserializer.deserialize_map(PortConfigVisitor)
172+
}
173+
}
174+
175+
/// A `MapAccess` adaptor that yields the already-consumed first key before
176+
/// delegating the rest of the map to the inner `MapAccess`.
177+
struct ReplayFirstKey<A> {
178+
first_key: Option<String>,
179+
inner: A,
180+
}
181+
182+
impl<'de, A> serde::de::MapAccess<'de> for ReplayFirstKey<A>
183+
where
184+
A: serde::de::MapAccess<'de>,
185+
{
186+
type Error = A::Error;
187+
188+
fn next_key_seed<K>(
189+
&mut self,
190+
seed: K,
191+
) -> Result<Option<K::Value>, Self::Error>
192+
where
193+
K: serde::de::DeserializeSeed<'de>,
194+
{
195+
match self.first_key.take() {
196+
Some(first_key) => {
197+
use serde::de::IntoDeserializer;
198+
let de = first_key.into_deserializer();
199+
seed.deserialize(de).map(Some)
200+
}
201+
None => self.inner.next_key_seed(seed),
202+
}
203+
}
204+
205+
fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
206+
where
207+
V: serde::de::DeserializeSeed<'de>,
208+
{
209+
self.inner.next_value_seed(seed)
210+
}
211+
212+
fn size_hint(&self) -> Option<usize> {
213+
let inner = self.inner.size_hint();
214+
match self.first_key {
215+
Some(_) => inner.map(|n| n + 1),
216+
None => inner,
217+
}
218+
}
219+
}
220+
221+
// The descriptions and shape here must stay in sync with the variant doc
222+
// comments and the hand-rolled Serialize/Deserialize impls above.
223+
impl JsonSchema for UserSpecifiedPortConfig {
224+
fn schema_name() -> String {
225+
"UserSpecifiedPortConfig".to_string()
226+
}
227+
228+
fn json_schema(
229+
generator: &mut schemars::r#gen::SchemaGenerator,
230+
) -> schemars::schema::Schema {
231+
use schemars::schema::InstanceType;
232+
use schemars::schema::Metadata;
233+
use schemars::schema::ObjectValidation;
234+
use schemars::schema::Schema;
235+
use schemars::schema::SchemaObject;
236+
use schemars::schema::SubschemaValidation;
237+
238+
let mut manual =
239+
generator.subschema_for::<ManualPortConfig>().into_object();
240+
manual.metadata().description =
241+
Some("A manually-configured port.".to_string());
242+
243+
let ddm_auto = SchemaObject {
244+
metadata: Some(Box::new(Metadata {
245+
description: Some(
246+
"A port configured automatically via DDM.".to_string(),
247+
),
248+
..Default::default()
249+
})),
250+
instance_type: Some(InstanceType::Object.into()),
251+
object: Some(Box::new(ObjectValidation {
252+
additional_properties: Some(Box::new(Schema::Bool(false))),
253+
..Default::default()
254+
})),
255+
..Default::default()
256+
};
257+
258+
SchemaObject {
259+
metadata: Some(Box::new(Metadata {
260+
description: Some(
261+
"A user-specified port configuration.".to_string(),
262+
),
263+
..Default::default()
264+
})),
265+
subschemas: Some(Box::new(SubschemaValidation {
266+
any_of: Some(vec![
267+
Schema::Object(manual),
268+
Schema::Object(ddm_auto),
269+
]),
270+
..Default::default()
271+
})),
272+
..Default::default()
273+
}
274+
.into()
275+
}
114276
}
115277

116278
/// User-specified version of `UplinkAddressConfig`.

0 commit comments

Comments
 (0)