Skip to main content

netget/server/netconf/
rpc.rs

1//! RFC 6241 envelopes shared by both roles: `<hello>`, `<rpc>` and `<rpc-reply>`.
2//!
3//! Everything here is a pure transformation between bounded XML documents and structured
4//! values. Rust owns message identity (`message-id`, `session-id`), the base-namespace
5//! envelope, capability negotiation and the datastore rules a peer can check without asking
6//! anyone; a handler decides only what the protocol leaves open — data, acceptance, errors.
7use super::xml::{self, Attribute, Document, Node};
8use anyhow::{bail, ensure, Context, Result};
9use serde_json::{json, Map, Value};
10
11pub const NC: &str = "urn:ietf:params:xml:ns:netconf:base:1.0";
12pub const BASE_10: &str = "urn:ietf:params:netconf:base:1.0";
13pub const BASE_11: &str = "urn:ietf:params:netconf:base:1.1";
14pub const WRITABLE_RUNNING: &str = "urn:ietf:params:netconf:capability:writable-running:1.0";
15pub const CANDIDATE: &str = "urn:ietf:params:netconf:capability:candidate:1.0";
16pub const STARTUP: &str = "urn:ietf:params:netconf:capability:startup:1.0";
17pub const VALIDATE_10: &str = "urn:ietf:params:netconf:capability:validate:1.0";
18pub const VALIDATE_11: &str = "urn:ietf:params:netconf:capability:validate:1.1";
19pub const XPATH: &str = "urn:ietf:params:netconf:capability:xpath:1.0";
20pub const ROLLBACK: &str = "urn:ietf:params:netconf:capability:rollback-on-error:1.0";
21
22pub const MAX_CAPABILITIES: usize = 256;
23pub const MAX_CAPABILITY_BYTES: usize = 1024;
24pub const MAX_MESSAGE_ID_BYTES: usize = 256;
25
26/// RFC 6241 Appendix A error tags.
27pub const ERROR_TAGS: &[&str] = &[
28    "in-use",
29    "invalid-value",
30    "too-big",
31    "missing-attribute",
32    "bad-attribute",
33    "unknown-attribute",
34    "missing-element",
35    "bad-element",
36    "unknown-element",
37    "unknown-namespace",
38    "access-denied",
39    "lock-denied",
40    "resource-denied",
41    "rollback-failed",
42    "data-exists",
43    "data-missing",
44    "operation-not-supported",
45    "operation-failed",
46    "malformed-message",
47];
48pub const ERROR_TYPES: &[&str] = &["transport", "rpc", "protocol", "application"];
49
50/// Base operations this implementation understands. Anything outside the base namespace is
51/// handed to the handler as a custom RPC; base operations not listed here are refused.
52pub const BASE_OPERATIONS: &[&str] = &[
53    "get",
54    "get-config",
55    "edit-config",
56    "lock",
57    "unlock",
58    "close-session",
59    "kill-session",
60    "commit",
61    "discard-changes",
62    "validate",
63];
64
65pub fn capability_ok(uri: &str) -> bool {
66    !uri.is_empty()
67        && uri.len() <= MAX_CAPABILITY_BYTES
68        && uri.is_ascii()
69        && !uri
70            .bytes()
71            .any(|b| b.is_ascii_whitespace() || b < 0x20 || b == 0x7f)
72        && uri.contains(':')
73}
74
75fn escape_text(text: &str) -> String {
76    text.replace('&', "&amp;")
77        .replace('<', "&lt;")
78        .replace('>', "&gt;")
79}
80fn escape_attribute(text: &str) -> String {
81    escape_text(text)
82        .replace('"', "&quot;")
83        .replace('\r', "&#13;")
84        .replace('\n', "&#10;")
85        .replace('\t', "&#9;")
86}
87
88/// Build a `<hello>`; the server includes its `session-id`, a client must not.
89pub fn hello(capabilities: &[String], session_id: Option<u32>) -> Result<Vec<u8>> {
90    ensure!(
91        !capabilities.is_empty() && capabilities.len() <= MAX_CAPABILITIES,
92        "NETCONF capability count"
93    );
94    let mut out =
95        format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><hello xmlns=\"{NC}\"><capabilities>");
96    for capability in capabilities {
97        ensure!(capability_ok(capability), "NETCONF capability URI");
98        out.push_str("<capability>");
99        out.push_str(&escape_text(capability));
100        out.push_str("</capability>");
101    }
102    out.push_str("</capabilities>");
103    if let Some(id) = session_id {
104        ensure!(id > 0, "NETCONF session-id must be positive");
105        out.push_str(&format!("<session-id>{id}</session-id>"));
106    }
107    out.push_str("</hello>");
108    xml::parse(out.as_bytes())?;
109    Ok(out.into_bytes())
110}
111
112pub struct Hello {
113    pub capabilities: Vec<String>,
114    pub session_id: Option<u32>,
115}
116
117pub fn parse_hello(message: &[u8]) -> Result<Hello> {
118    let doc = xml::parse(message)?;
119    ensure!(
120        xml::is_element(&doc, 0, "hello", NC),
121        "NETCONF expected <hello> in the base namespace"
122    );
123    let mut capabilities = Vec::new();
124    let mut session_id = None;
125    let mut seen_capabilities = false;
126    for child in xml::children(&doc, 0)? {
127        if xml::is_element(&doc, child, "capabilities", NC) {
128            ensure!(!seen_capabilities, "NETCONF duplicate <capabilities>");
129            seen_capabilities = true;
130            for capability in xml::children(&doc, child)? {
131                ensure!(
132                    xml::is_element(&doc, capability, "capability", NC),
133                    "NETCONF unexpected element in <capabilities>"
134                );
135                let uri = xml::text(&doc, capability)?.trim().to_owned();
136                ensure!(capability_ok(&uri), "NETCONF capability URI");
137                ensure!(
138                    capabilities.len() < MAX_CAPABILITIES,
139                    "NETCONF capability count"
140                );
141                if !capabilities.contains(&uri) {
142                    capabilities.push(uri);
143                }
144            }
145        } else if xml::is_element(&doc, child, "session-id", NC) {
146            ensure!(session_id.is_none(), "NETCONF duplicate <session-id>");
147            let text = xml::text(&doc, child)?;
148            let id: u32 = text
149                .trim()
150                .parse()
151                .context("NETCONF session-id must be a 32-bit number")?;
152            ensure!(id > 0, "NETCONF session-id must be positive");
153            session_id = Some(id);
154        }
155        // RFC 6241 §8.1: unknown hello content is ignored for extensibility.
156    }
157    ensure!(
158        seen_capabilities && !capabilities.is_empty(),
159        "NETCONF <hello> without capabilities"
160    );
161    Ok(Hello {
162        capabilities,
163        session_id,
164    })
165}
166
167/// Framing both peers agree on, or `None` when they share no base version.
168pub fn negotiate(local: &[String], remote: &[String]) -> Option<super::wire::Framing> {
169    let both = |v: &str| local.iter().any(|c| c == v) && remote.iter().any(|c| c == v);
170    if both(BASE_11) {
171        Some(super::wire::Framing::Chunked)
172    } else if both(BASE_10) {
173        Some(super::wire::Framing::Delimiter)
174    } else {
175        None
176    }
177}
178
179/// One `<rpc-error>`, already validated.
180#[derive(Clone, Debug, PartialEq)]
181pub struct RpcError {
182    pub error_type: String,
183    pub tag: String,
184    pub severity: String,
185    pub message: Option<String>,
186    pub path: Option<String>,
187    pub info: Option<Document>,
188}
189impl RpcError {
190    pub fn new(error_type: &str, tag: &str, message: &str) -> Self {
191        Self {
192            error_type: error_type.into(),
193            tag: tag.into(),
194            severity: "error".into(),
195            message: Some(message.into()),
196            path: None,
197            info: None,
198        }
199    }
200    pub fn from_value(v: &Value) -> Result<Self> {
201        let obj = v.as_object().context("NETCONF error must be an object")?;
202        for key in obj.keys() {
203            ensure!(
204                matches!(
205                    key.as_str(),
206                    "error_type"
207                        | "error_tag"
208                        | "error_severity"
209                        | "error_message"
210                        | "error_path"
211                        | "error_info_xml"
212                ),
213                "NETCONF error has unknown field '{key}'"
214            );
215        }
216        let s = |k: &str| {
217            obj.get(k)
218                .map(|v| {
219                    v.as_str()
220                        .with_context(|| format!("NETCONF {k} must be a string"))
221                })
222                .transpose()
223        };
224        let error_type = s("error_type")?.unwrap_or("application");
225        let tag = s("error_tag")?.context("NETCONF error requires error_tag")?;
226        let severity = s("error_severity")?.unwrap_or("error");
227        ensure!(
228            ERROR_TYPES.contains(&error_type),
229            "NETCONF error_type must be transport, rpc, protocol or application"
230        );
231        ensure!(
232            ERROR_TAGS.contains(&tag),
233            "NETCONF error_tag '{tag}' is not an RFC 6241 error tag"
234        );
235        ensure!(
236            matches!(severity, "error" | "warning"),
237            "NETCONF error_severity must be error or warning"
238        );
239        let bounded = |v: Option<&str>| -> Result<Option<String>> {
240            v.map(|t| {
241                ensure!(t.len() <= 4096, "NETCONF error text bound");
242                Ok(t.to_owned())
243            })
244            .transpose()
245        };
246        let info = s("error_info_xml")?
247            .map(|t| xml::parse_fragment_in(t, Some(NC)))
248            .transpose()?;
249        Ok(Self {
250            error_type: error_type.into(),
251            tag: tag.into(),
252            severity: severity.into(),
253            message: bounded(s("error_message")?)?,
254            path: bounded(s("error_path")?)?,
255            info,
256        })
257    }
258    fn render(&self, out: &mut String) -> Result<()> {
259        out.push_str("<rpc-error>");
260        out.push_str(&format!("<error-type>{}</error-type>", self.error_type));
261        out.push_str(&format!("<error-tag>{}</error-tag>", self.tag));
262        out.push_str(&format!(
263            "<error-severity>{}</error-severity>",
264            self.severity
265        ));
266        if let Some(path) = &self.path {
267            out.push_str(&format!("<error-path>{}</error-path>", escape_text(path)));
268        }
269        if let Some(message) = &self.message {
270            out.push_str(&format!(
271                "<error-message xml:lang=\"en\">{}</error-message>",
272                escape_text(message)
273            ));
274        }
275        if let Some(info) = &self.info {
276            out.push_str("<error-info>");
277            out.push_str(&xml::render_fragment(info)?);
278            out.push_str("</error-info>");
279        }
280        out.push_str("</rpc-error>");
281        Ok(())
282    }
283    pub fn to_value(&self) -> Result<Value> {
284        let mut v = json!({"error_type": self.error_type, "error_tag": self.tag, "error_severity": self.severity});
285        if let Some(m) = &self.message {
286            v["error_message"] = json!(m);
287        }
288        if let Some(p) = &self.path {
289            v["error_path"] = json!(p);
290        }
291        if let Some(i) = &self.info {
292            v["error_info_xml"] = json!(xml::render_fragment(i)?);
293        }
294        Ok(v)
295    }
296}
297
298/// What a reply carries besides its identity.
299#[derive(Clone, Debug)]
300pub enum ReplyBody {
301    Ok,
302    /// `<data>` for get/get-config.
303    Data(Document),
304    /// Output elements placed directly under `<rpc-reply>` (custom RPCs).
305    Output(Document),
306    Errors(Vec<RpcError>),
307}
308
309/// Render an `<rpc-reply>`, echoing every attribute of the request's `<rpc>` as RFC 6241
310/// §4.2 requires. `attributes` are those of the incoming `<rpc>`, `message-id` among them.
311pub fn reply(
312    attributes: &[Attribute],
313    bindings: &[xml::Binding],
314    body: &ReplyBody,
315) -> Result<Vec<u8>> {
316    let mut out = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rpc-reply xmlns=\"{NC}\"");
317    for b in bindings {
318        // Prefixes the request declared on <rpc> may be used by echoed attributes.
319        if b.prefix.is_empty() || b.prefix == "xml" {
320            continue;
321        }
322        out.push_str(&format!(
323            " xmlns:{}=\"{}\"",
324            b.prefix,
325            escape_attribute(&b.uri)
326        ));
327    }
328    for a in attributes {
329        out.push_str(&format!(" {}=\"{}\"", a.name, escape_attribute(&a.value)));
330    }
331    out.push('>');
332    match body {
333        ReplyBody::Ok => out.push_str("<ok/>"),
334        ReplyBody::Data(doc) => {
335            out.push_str("<data>");
336            out.push_str(&xml::render_fragment(doc)?);
337            out.push_str("</data>");
338        }
339        ReplyBody::Output(doc) => out.push_str(&xml::render_fragment(doc)?),
340        ReplyBody::Errors(errors) => {
341            ensure!(
342                !errors.is_empty() && errors.len() <= 64,
343                "NETCONF rpc-error count"
344            );
345            for e in errors {
346                e.render(&mut out)?;
347            }
348        }
349    }
350    out.push_str("</rpc-reply>");
351    ensure!(
352        out.len() <= super::wire::MAX_MESSAGE_BYTES,
353        "NETCONF reply byte bound"
354    );
355    xml::parse(out.as_bytes()).context("NETCONF rendered reply is not well-formed")?;
356    Ok(out.into_bytes())
357}
358
359/// Capability-dependent datastore rule shared by both roles.
360pub fn datastore_allowed(datastore: &str, capabilities: &[String], writing: bool) -> Result<()> {
361    let has = |c: &str| capabilities.iter().any(|v| v == c);
362    match datastore {
363        "running" => ensure!(
364            !writing || has(WRITABLE_RUNNING),
365            "running is not writable without :writable-running"
366        ),
367        "candidate" => ensure!(
368            has(CANDIDATE),
369            "candidate datastore requires the :candidate capability"
370        ),
371        "startup" => ensure!(
372            has(STARTUP),
373            "startup datastore requires the :startup capability"
374        ),
375        other => bail!("datastore '{other}' is not supported; url and other sources are excluded"),
376    }
377    Ok(())
378}
379
380/// A parsed `<rpc>` as the server sees it.
381pub struct IncomingRpc {
382    pub message_id: String,
383    pub attributes: Vec<Attribute>,
384    pub bindings: Vec<xml::Binding>,
385    pub operation: String,
386    pub namespace: String,
387    /// Structured event fields for the handler.
388    pub fields: Map<String, Value>,
389}
390
391/// Why an incoming message could not become an [`IncomingRpc`]: either it is answerable with
392/// an `<rpc-error>` (we know its attributes), or it is not an rpc at all.
393pub enum RpcRefusal {
394    Reply {
395        attributes: Vec<Attribute>,
396        bindings: Vec<xml::Binding>,
397        error: RpcError,
398    },
399    Fatal(anyhow::Error),
400}
401
402fn datastore(doc: &Document, element: usize) -> Result<String, RpcError> {
403    let kids = xml::children(doc, element)
404        .map_err(|e| RpcError::new("protocol", "malformed-message", &e.to_string()))?;
405    if kids.len() != 1 {
406        return Err(RpcError::new(
407            "protocol",
408            "missing-element",
409            "exactly one datastore is required",
410        ));
411    }
412    let (name, ns, _) = xml::element(doc, kids[0])
413        .map_err(|e| RpcError::new("protocol", "malformed-message", &e.to_string()))?;
414    if ns != NC {
415        return Err(RpcError::new(
416            "protocol",
417            "unknown-namespace",
418            "datastore must be in the base namespace",
419        ));
420    }
421    Ok(name.to_owned())
422}
423
424fn subtree(doc: &Document, element: usize) -> Result<String, RpcError> {
425    let inner = xml::inner(doc, element)
426        .map_err(|e| RpcError::new("protocol", "malformed-message", &e.to_string()))?;
427    xml::render_fragment(&inner)
428        .map_err(|e| RpcError::new("application", "too-big", &e.to_string()))
429}
430
431/// Parse and check an incoming `<rpc>` against the server's own capabilities.
432pub fn parse_rpc(message: &[u8], capabilities: &[String]) -> Result<IncomingRpc, RpcRefusal> {
433    let doc = xml::parse(message).map_err(RpcRefusal::Fatal)?;
434    let (name, ns, attrs) = xml::element(&doc, 0).map_err(RpcRefusal::Fatal)?;
435    if name != "rpc" || ns != NC {
436        return Err(RpcRefusal::Fatal(anyhow::anyhow!(
437            "NETCONF expected <rpc> in the base namespace"
438        )));
439    }
440    let attributes = attrs.to_vec();
441    let bindings = match &doc.nodes[0] {
442        Node::Start { bindings, .. } => bindings.clone(),
443        _ => Vec::new(),
444    };
445    let refuse = |error: RpcError| RpcRefusal::Reply {
446        attributes: attributes.clone(),
447        bindings: bindings.clone(),
448        error,
449    };
450    let message_id = attributes
451        .iter()
452        .find(|a| a.name == "message-id" && a.namespace.is_empty())
453        .map(|a| a.value.clone());
454    let Some(message_id) = message_id else {
455        let mut error = RpcError::new("rpc", "missing-attribute", "message-id is required");
456        error.info = xml::parse_fragment_in(
457            "<bad-attribute>message-id</bad-attribute><bad-element>rpc</bad-element>",
458            Some(NC),
459        )
460        .ok();
461        return Err(refuse(error));
462    };
463    if message_id.len() > MAX_MESSAGE_ID_BYTES {
464        return Err(refuse(RpcError::new(
465            "rpc",
466            "bad-attribute",
467            "message-id is too long",
468        )));
469    }
470    let kids = xml::children(&doc, 0)
471        .map_err(|e| refuse(RpcError::new("rpc", "malformed-message", &e.to_string())))?;
472    if kids.len() != 1 {
473        return Err(refuse(RpcError::new(
474            "rpc",
475            "malformed-message",
476            "an rpc carries exactly one operation",
477        )));
478    }
479    let op = kids[0];
480    let (operation, op_ns, _) = xml::element(&doc, op).map_err(RpcRefusal::Fatal)?;
481    let (operation, op_ns) = (operation.to_owned(), op_ns.to_owned());
482    let mut fields = Map::new();
483    fields.insert("message_id".into(), json!(message_id));
484    fields.insert("operation".into(), json!(operation));
485    fields.insert("namespace".into(), json!(op_ns));
486    if op_ns != NC {
487        fields.insert("custom".into(), json!(true));
488        let rendered = xml::inner(&doc, op)
489            .and_then(|d| xml::render_fragment(&d))
490            .map_err(|e| refuse(RpcError::new("application", "too-big", &e.to_string())))?;
491        fields.insert("input_xml".into(), json!(rendered));
492        return Ok(IncomingRpc {
493            message_id,
494            attributes,
495            bindings,
496            operation,
497            namespace: op_ns,
498            fields,
499        });
500    }
501    if !BASE_OPERATIONS.contains(&operation.as_str()) {
502        return Err(refuse(RpcError::new(
503            "protocol",
504            "operation-not-supported",
505            "this base operation is not implemented",
506        )));
507    }
508    let children = xml::children(&doc, op).map_err(|e| {
509        refuse(RpcError::new(
510            "protocol",
511            "malformed-message",
512            &e.to_string(),
513        ))
514    })?;
515    let mut seen = std::collections::BTreeSet::new();
516    for &child in &children {
517        let (child_name, child_ns, child_attrs) =
518            xml::element(&doc, child).map_err(RpcRefusal::Fatal)?;
519        if child_ns != NC {
520            return Err(refuse(RpcError::new(
521                "protocol",
522                "unknown-namespace",
523                "operation parameters must be in the base namespace",
524            )));
525        }
526        if !seen.insert(child_name.to_owned()) {
527            return Err(refuse(RpcError::new(
528                "protocol",
529                "bad-element",
530                "duplicate operation parameter",
531            )));
532        }
533        let writing = matches!(
534            operation.as_str(),
535            "edit-config" | "commit" | "discard-changes"
536        );
537        match (operation.as_str(), child_name) {
538            ("get-config" | "validate", "source")
539            | ("edit-config" | "lock" | "unlock", "target") => {
540                let store = datastore(&doc, child).map_err(refuse)?;
541                if let Err(e) = datastore_allowed(
542                    &store,
543                    capabilities,
544                    operation == "edit-config" || (writing && child_name == "target"),
545                ) {
546                    return Err(refuse(RpcError::new(
547                        "protocol",
548                        "invalid-value",
549                        &e.to_string(),
550                    )));
551                }
552                fields.insert(child_name.into(), json!(store));
553            }
554            ("get" | "get-config", "filter") => {
555                let kind = child_attrs
556                    .iter()
557                    .find(|a| a.name == "type")
558                    .map(|a| a.value.as_str())
559                    .unwrap_or("subtree");
560                match kind {
561                    "subtree" => {
562                        fields.insert("filter_type".into(), json!("subtree"));
563                        fields.insert(
564                            "filter_xml".into(),
565                            json!(subtree(&doc, child).map_err(refuse)?),
566                        );
567                    }
568                    "xpath" if capabilities.iter().any(|c| c == XPATH) => {
569                        let select = child_attrs
570                            .iter()
571                            .find(|a| a.name == "select")
572                            .map(|a| a.value.clone());
573                        let Some(select) = select else {
574                            return Err(refuse(RpcError::new(
575                                "protocol",
576                                "missing-attribute",
577                                "xpath filter requires select",
578                            )));
579                        };
580                        fields.insert("filter_type".into(), json!("xpath"));
581                        fields.insert("filter_select".into(), json!(select));
582                    }
583                    _ => {
584                        return Err(refuse(RpcError::new(
585                            "protocol",
586                            "bad-attribute",
587                            "unsupported filter type",
588                        )))
589                    }
590                }
591            }
592            ("edit-config", "config") => {
593                fields.insert(
594                    "config_xml".into(),
595                    json!(subtree(&doc, child).map_err(refuse)?),
596                );
597            }
598            ("edit-config", "default-operation") => {
599                let v = xml::text(&doc, child).map_err(RpcRefusal::Fatal)?;
600                if !matches!(v.trim(), "merge" | "replace" | "none") {
601                    return Err(refuse(RpcError::new(
602                        "protocol",
603                        "invalid-value",
604                        "default-operation must be merge, replace or none",
605                    )));
606                }
607                fields.insert("default_operation".into(), json!(v.trim()));
608            }
609            ("edit-config", "test-option") => {
610                let v = xml::text(&doc, child).map_err(RpcRefusal::Fatal)?;
611                let validate = capabilities
612                    .iter()
613                    .any(|c| c == VALIDATE_10 || c == VALIDATE_11);
614                if !validate || !matches!(v.trim(), "test-then-set" | "set" | "test-only") {
615                    return Err(refuse(RpcError::new(
616                        "protocol",
617                        "invalid-value",
618                        "test-option requires :validate and a known value",
619                    )));
620                }
621                fields.insert("test_option".into(), json!(v.trim()));
622            }
623            ("edit-config", "error-option") => {
624                let v = xml::text(&doc, child).map_err(RpcRefusal::Fatal)?;
625                let ok = match v.trim() {
626                    "stop-on-error" | "continue-on-error" => true,
627                    "rollback-on-error" => capabilities.iter().any(|c| c == ROLLBACK),
628                    _ => false,
629                };
630                if !ok {
631                    return Err(refuse(RpcError::new(
632                        "protocol",
633                        "invalid-value",
634                        "unsupported error-option",
635                    )));
636                }
637                fields.insert("error_option".into(), json!(v.trim()));
638            }
639            ("kill-session", "session-id") => {
640                let v = xml::text(&doc, child).map_err(RpcRefusal::Fatal)?;
641                let id: u32 = v.trim().parse().map_err(|_| {
642                    refuse(RpcError::new(
643                        "protocol",
644                        "invalid-value",
645                        "session-id must be a number",
646                    ))
647                })?;
648                fields.insert("session_id".into(), json!(id));
649            }
650            ("commit", "confirmed" | "confirm-timeout" | "persist" | "persist-id") => {
651                return Err(refuse(RpcError::new(
652                    "protocol",
653                    "operation-not-supported",
654                    "confirmed commit is not supported",
655                )));
656            }
657            ("validate", "config") => {
658                return Err(refuse(RpcError::new(
659                    "protocol",
660                    "operation-not-supported",
661                    "validate supports a datastore source only",
662                )));
663            }
664            ("edit-config", "url") | ("get-config" | "validate", "url") => {
665                return Err(refuse(RpcError::new(
666                    "protocol",
667                    "operation-not-supported",
668                    "url is excluded",
669                )));
670            }
671            _ => {
672                return Err(refuse(RpcError::new(
673                    "protocol",
674                    "unknown-element",
675                    "unexpected operation parameter",
676                )))
677            }
678        }
679    }
680    let need = |key: &str| -> Result<(), RpcRefusal> {
681        if fields.contains_key(key) {
682            Ok(())
683        } else {
684            Err(refuse(RpcError::new(
685                "protocol",
686                "missing-element",
687                &format!("{key} is required"),
688            )))
689        }
690    };
691    match operation.as_str() {
692        "get-config" => need("source")?,
693        "edit-config" => {
694            need("target")?;
695            need("config_xml")?;
696        }
697        "lock" | "unlock" => need("target")?,
698        "kill-session" => need("session_id")?,
699        "validate" => {
700            if !capabilities
701                .iter()
702                .any(|c| c == VALIDATE_10 || c == VALIDATE_11)
703            {
704                return Err(refuse(RpcError::new(
705                    "protocol",
706                    "operation-not-supported",
707                    "validate requires the :validate capability",
708                )));
709            }
710            need("source")?
711        }
712        "commit" | "discard-changes" if !capabilities.iter().any(|c| c == CANDIDATE) => {
713            return Err(refuse(RpcError::new(
714                "protocol",
715                "operation-not-supported",
716                "requires the :candidate capability",
717            )));
718        }
719        _ => {}
720    }
721    Ok(IncomingRpc {
722        message_id,
723        attributes,
724        bindings,
725        operation,
726        namespace: op_ns,
727        fields,
728    })
729}
730
731/// A client-side request, built from a structured action.
732pub fn build_rpc(
733    message_id: u64,
734    action: &Value,
735    server_capabilities: &[String],
736) -> Result<(String, Vec<u8>)> {
737    let operation = action["operation"]
738        .as_str()
739        .context("NETCONF operation required")?;
740    let s = |k: &str| action.get(k).and_then(Value::as_str);
741    let mut body = String::new();
742    let store = |k: &str, writing: bool, body: &mut String| -> Result<()> {
743        let v = s(k).with_context(|| format!("NETCONF {operation} requires {k}"))?;
744        datastore_allowed(v, server_capabilities, writing)?;
745        body.push_str(&format!("<{k}><{v}/></{k}>"));
746        Ok(())
747    };
748    let filter = |body: &mut String| -> Result<()> {
749        if let Some(f) = s("filter_xml") {
750            let doc = xml::parse_fragment(f)?;
751            body.push_str("<filter type=\"subtree\">");
752            body.push_str(&xml::render_fragment(&doc)?);
753            body.push_str("</filter>");
754        }
755        Ok(())
756    };
757    let op_name = match operation {
758        "get" => {
759            filter(&mut body)?;
760            "get"
761        }
762        "get-config" => {
763            store("source", false, &mut body)?;
764            filter(&mut body)?;
765            "get-config"
766        }
767        "edit-config" => {
768            store("target", true, &mut body)?;
769            if let Some(d) = s("default_operation") {
770                ensure!(
771                    matches!(d, "merge" | "replace" | "none"),
772                    "default_operation must be merge, replace or none"
773                );
774                body.push_str(&format!("<default-operation>{d}</default-operation>"));
775            }
776            if let Some(e) = s("error_option") {
777                ensure!(
778                    matches!(
779                        e,
780                        "stop-on-error" | "continue-on-error" | "rollback-on-error"
781                    ),
782                    "unknown error_option"
783                );
784                if e == "rollback-on-error" {
785                    ensure!(
786                        server_capabilities.iter().any(|c| c == ROLLBACK),
787                        "server lacks :rollback-on-error"
788                    );
789                }
790                body.push_str(&format!("<error-option>{e}</error-option>"));
791            }
792            let config =
793                xml::parse_fragment(s("config_xml").context("edit-config requires config_xml")?)?;
794            ensure!(
795                !config.nodes.is_empty(),
796                "edit-config requires non-empty config_xml"
797            );
798            body.push_str("<config>");
799            body.push_str(&xml::render_fragment(&config)?);
800            body.push_str("</config>");
801            "edit-config"
802        }
803        "lock" | "unlock" => {
804            store("target", false, &mut body)?;
805            operation
806        }
807        "commit" | "discard-changes" => {
808            ensure!(
809                server_capabilities.iter().any(|c| c == CANDIDATE),
810                "server lacks :candidate"
811            );
812            operation
813        }
814        "validate" => {
815            ensure!(
816                server_capabilities
817                    .iter()
818                    .any(|c| c == VALIDATE_10 || c == VALIDATE_11),
819                "server lacks :validate"
820            );
821            store("source", false, &mut body)?;
822            "validate"
823        }
824        "close-session" => "close-session",
825        "kill-session" => {
826            let id = action["session_id"]
827                .as_u64()
828                .context("kill-session requires session_id")?;
829            ensure!(
830                id > 0 && id <= u64::from(u32::MAX),
831                "session_id out of range"
832            );
833            body.push_str(&format!("<session-id>{id}</session-id>"));
834            "kill-session"
835        }
836        "custom" => {
837            let input =
838                xml::parse_fragment(s("input_xml").context(
839                    "custom requires input_xml: one operation element with its namespace",
840                )?)?;
841            let top = input
842                .nodes
843                .iter()
844                .filter(|n| matches!(n, Node::Start { .. }))
845                .count();
846            ensure!(top >= 1, "custom input_xml needs an operation element");
847            let rendered = xml::render_fragment(&input)?;
848            let doc =
849                xml::parse(format!("<x xmlns=\"urn:netget:probe\">{rendered}</x>").as_bytes())?;
850            ensure!(
851                xml::children(&doc, 0)?.len() == 1,
852                "custom input_xml must be exactly one operation element"
853            );
854            let (_, ns, _) = xml::element(&doc, 1)?;
855            ensure!(
856                !ns.is_empty() && ns != NC,
857                "custom operation must be in its own non-base namespace"
858            );
859            body = rendered;
860            ""
861        }
862        other => bail!("unsupported NETCONF operation '{other}'"),
863    };
864    let message = if op_name.is_empty() {
865        format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rpc xmlns=\"{NC}\" message-id=\"{message_id}\">{body}</rpc>")
866    } else {
867        format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rpc xmlns=\"{NC}\" message-id=\"{message_id}\"><{op_name}>{body}</{op_name}></rpc>")
868    };
869    ensure!(
870        message.len() <= super::wire::MAX_MESSAGE_BYTES,
871        "NETCONF request byte bound"
872    );
873    xml::parse(message.as_bytes())?;
874    Ok((operation.to_owned(), message.into_bytes()))
875}
876
877/// A client-side view of an `<rpc-reply>`.
878pub struct IncomingReply {
879    pub message_id: Option<String>,
880    pub body: Value,
881}
882
883pub fn parse_reply(message: &[u8]) -> Result<IncomingReply> {
884    let doc = xml::parse(message)?;
885    let (name, ns, attrs) = xml::element(&doc, 0)?;
886    ensure!(
887        name == "rpc-reply" && ns == NC,
888        "NETCONF expected <rpc-reply> in the base namespace"
889    );
890    let message_id = attrs
891        .iter()
892        .find(|a| a.name == "message-id" && a.namespace.is_empty())
893        .map(|a| a.value.clone());
894    let kids = xml::children(&doc, 0)?;
895    let mut ok = false;
896    let mut data: Option<String> = None;
897    let mut errors = Vec::new();
898    let mut output = Vec::new();
899    for child in kids {
900        let (child_name, child_ns, _) = xml::element(&doc, child)?;
901        if child_ns == NC && child_name == "ok" {
902            ok = true;
903        } else if child_ns == NC && child_name == "data" {
904            ensure!(data.is_none(), "NETCONF duplicate <data>");
905            data = Some(xml::render_fragment(&xml::inner(&doc, child)?)?);
906        } else if child_ns == NC && child_name == "rpc-error" {
907            ensure!(errors.len() < 64, "NETCONF rpc-error count");
908            let mut e = Map::new();
909            for field in xml::children(&doc, child)? {
910                let (f, fns, _) = xml::element(&doc, field)?;
911                ensure!(fns == NC, "NETCONF rpc-error field namespace");
912                let key = match f {
913                    "error-type" => "error_type",
914                    "error-tag" => "error_tag",
915                    "error-severity" => "error_severity",
916                    "error-app-tag" => "error_app_tag",
917                    "error-path" => "error_path",
918                    "error-message" => "error_message",
919                    "error-info" => {
920                        e.insert(
921                            "error_info_xml".into(),
922                            json!(xml::render_fragment(&xml::inner(&doc, field)?)?),
923                        );
924                        continue;
925                    }
926                    _ => continue,
927                };
928                e.insert(key.into(), json!(xml::text(&doc, field)?.trim()));
929            }
930            ensure!(
931                e.contains_key("error_tag"),
932                "NETCONF rpc-error without error-tag"
933            );
934            errors.push(Value::Object(e));
935        } else {
936            let mut piece = xml::inner(&doc, 0)?;
937            let end = xml::end_index(&doc, child)?;
938            piece.nodes = doc.nodes[child..=end].to_vec();
939            output.push(xml::render_fragment(&piece)?);
940        }
941    }
942    let mut body = Map::new();
943    if ok {
944        body.insert("ok".into(), json!(true));
945    }
946    if let Some(d) = data {
947        body.insert("data_xml".into(), json!(d));
948    }
949    if !errors.is_empty() {
950        body.insert("errors".into(), Value::Array(errors));
951    }
952    if !output.is_empty() {
953        body.insert("output_xml".into(), json!(output.concat()));
954    }
955    ensure!(
956        !body.is_empty(),
957        "NETCONF <rpc-reply> carries no recognised content"
958    );
959    Ok(IncomingReply {
960        message_id,
961        body: Value::Object(body),
962    })
963}