Skip to main content

netget/server/restconf/
path.rs

1//! RESTCONF data resource identifiers (RFC 8040 section 3.5.3): `module:node/child=key1,key2`,
2//! percent-decoded, with the module carried forward from the first segment.
3use anyhow::{bail, ensure, Result};
4use serde_json::{json, Value as Json};
5
6pub const MAX_SEGMENTS: usize = 64;
7pub const MAX_PATH: usize = 4096;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct Segment {
11    pub module: Option<String>,
12    pub name: String,
13    pub keys: Option<Vec<String>>,
14}
15
16fn identifier(s: &str) -> bool {
17    !s.is_empty()
18        && s.len() <= 128
19        && s.chars()
20            .next()
21            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
22        && s.chars()
23            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
24}
25
26fn decode(s: &str) -> Result<String> {
27    let bytes = s.as_bytes();
28    let mut out = Vec::with_capacity(bytes.len());
29    let mut i = 0;
30    while i < bytes.len() {
31        if bytes[i] == b'%' {
32            ensure!(i + 2 < bytes.len(), "a truncated percent escape");
33            let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?;
34            out.push(
35                u8::from_str_radix(hex, 16).map_err(|_| anyhow::anyhow!("a bad percent escape"))?,
36            );
37            i += 3;
38        } else {
39            out.push(bytes[i]);
40            i += 1;
41        }
42    }
43    Ok(String::from_utf8(out)?)
44}
45
46/// Parse the part of a data URL after `{root}/data/` (empty means the whole datastore).
47pub fn parse(path: &str) -> Result<Vec<Segment>> {
48    ensure!(
49        path.len() <= MAX_PATH,
50        "the path is longer than {MAX_PATH} bytes"
51    );
52    if path.is_empty() {
53        return Ok(vec![]);
54    }
55    let mut out = Vec::new();
56    for raw in path.split('/') {
57        ensure!(
58            out.len() < MAX_SEGMENTS,
59            "more than {MAX_SEGMENTS} path segments"
60        );
61        ensure!(!raw.is_empty(), "an empty path segment");
62        let (ident, keys) = match raw.split_once('=') {
63            Some((i, k)) => (
64                i,
65                Some(k.split(',').map(decode).collect::<Result<Vec<_>>>()?),
66            ),
67            None => (raw, None),
68        };
69        let (module, name) = match ident.split_once(':') {
70            Some((m, n)) => (Some(m.to_owned()), n.to_owned()),
71            None => (None, ident.to_owned()),
72        };
73        if let Some(m) = &module {
74            ensure!(identifier(m), "{m:?} is not a module name");
75        } else if out.is_empty() {
76            bail!("the first path segment names its module, e.g. example:{ident}");
77        }
78        // `module:` alone addresses the module's top-level nodes (FreeCONF's convention for
79        // modules without a top container).
80        let module_root =
81            name.is_empty() && module.is_some() && keys.is_none() && path.split('/').count() == 1;
82        ensure!(
83            module_root || identifier(&name),
84            "{name:?} is not a node name"
85        );
86        out.push(Segment { module, name, keys });
87    }
88    Ok(out)
89}
90
91pub fn to_json(segments: &[Segment]) -> Json {
92    json!(segments
93        .iter()
94        .map(|s| {
95            let mut v = json!({"name": s.name});
96            if let Some(m) = &s.module {
97                v["module"] = json!(m);
98            }
99            if let Some(k) = &s.keys {
100                v["keys"] = json!(k);
101            }
102            v
103        })
104        .collect::<Vec<_>>())
105}
106
107/// The module of the last segment that names one (the target's namespace).
108pub fn target_module(segments: &[Segment]) -> Option<&str> {
109    segments.iter().rev().find_map(|s| s.module.as_deref())
110}
111
112/// Percent-encode a key value for a URL path segment.
113pub fn encode_key(k: &str) -> String {
114    k.bytes()
115        .map(|b| {
116            if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
117                (b as char).to_string()
118            } else {
119                format!("%{b:02X}")
120            }
121        })
122        .collect()
123}