Skip to main content

rns_git/
perms_cli.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::PathBuf;
4use std::time::Duration;
5
6use rns_core::msgpack::{self, Value};
7
8use crate::client::{decode_status, SyncClient};
9use crate::config::ClientConfig;
10use crate::logging;
11use crate::protocol;
12use crate::util::{default_reticulum_dir, default_rngit_dir, parse_rns_url_with_aliases};
13use crate::{Error, Result};
14
15const PERMS_TIMEOUT: Duration = Duration::from_secs(120);
16
17pub fn main<I>(args: I) -> Result<()>
18where
19    I: IntoIterator<Item = String>,
20{
21    let options = PermsOptions::parse(args)?;
22    let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
23    let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
24    let mut config = ClientConfig::load_or_create_for_run(rngit_dir, rns_dir)?;
25    logging::init_file_logger(&config.dir.join("client_log"), config.log_level)?;
26    let (dest_hash, target_path) =
27        parse_rns_url_with_aliases(&options.remote, &config.destination_aliases)?;
28    if let Some(identity_path) = options.identity_path {
29        config.identity_path = identity_path;
30    }
31
32    let client = SyncClient::connect(config, dest_hash)?;
33    let mut transport = NetPermsTransport {
34        client,
35        target_path,
36    };
37    run_perms_command(
38        &mut transport,
39        options.content_path.as_deref(),
40        io::stdout(),
41    )
42}
43
44trait PermsTransport {
45    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>>;
46
47    fn request_with_timeout(&mut self, data: Vec<u8>, _timeout: Duration) -> Result<Vec<u8>> {
48        self.request(data)
49    }
50
51    fn target_path(&self) -> &str;
52}
53
54struct NetPermsTransport {
55    client: SyncClient,
56    target_path: String,
57}
58
59impl PermsTransport for NetPermsTransport {
60    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
61        self.request_with_timeout(data, PERMS_TIMEOUT)
62    }
63
64    fn request_with_timeout(&mut self, data: Vec<u8>, timeout: Duration) -> Result<Vec<u8>> {
65        let response = self
66            .client
67            .request_with_timeout(protocol::PATH_PERMS, data, timeout)?;
68        let bytes = protocol::response_bin(&response.data)?;
69        decode_status(bytes)
70    }
71
72    fn target_path(&self) -> &str {
73        &self.target_path
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78struct PermsOptions {
79    config_dir: Option<PathBuf>,
80    rns_config_dir: Option<PathBuf>,
81    identity_path: Option<PathBuf>,
82    content_path: Option<PathBuf>,
83    remote: String,
84}
85
86impl PermsOptions {
87    fn parse<I>(args: I) -> Result<Self>
88    where
89        I: IntoIterator<Item = String>,
90    {
91        let mut config_dir = None;
92        let mut rns_config_dir = None;
93        let mut identity_path = None;
94        let mut content_path = None;
95        let mut remote = None;
96        let mut iter = args.into_iter();
97        while let Some(arg) = iter.next() {
98            match arg.as_str() {
99                "--config" => config_dir = Some(next_path(&mut iter, "--config")?),
100                "--rnsconfig" => rns_config_dir = Some(next_path(&mut iter, "--rnsconfig")?),
101                "-i" | "--identity" => identity_path = Some(next_path(&mut iter, "--identity")?),
102                "--content" => content_path = Some(next_path(&mut iter, "--content")?),
103                "-h" | "--help" => return Err(Error::msg(usage())),
104                other if other.starts_with('-') => {
105                    return Err(Error::msg(format!("unknown perms option {other}")));
106                }
107                _ => {
108                    if remote.replace(arg).is_some() {
109                        return Err(Error::msg(
110                            "perms takes exactly one group or repository URL",
111                        ));
112                    }
113                }
114            }
115        }
116        Ok(Self {
117            config_dir,
118            rns_config_dir,
119            identity_path,
120            content_path,
121            remote: remote.ok_or_else(|| Error::msg(usage()))?,
122        })
123    }
124}
125
126fn next_path(iter: &mut impl Iterator<Item = String>, flag: &str) -> Result<PathBuf> {
127    iter.next()
128        .map(PathBuf::from)
129        .ok_or_else(|| Error::msg(format!("{flag} requires a value")))
130}
131
132fn run_perms_command(
133    transport: &mut impl PermsTransport,
134    content_path: Option<&std::path::Path>,
135    mut output: impl Write,
136) -> Result<()> {
137    let target = target_from_path(transport.target_path())?;
138    match content_path {
139        Some(path) => {
140            let content = fs::read_to_string(path)?;
141            transport
142                .request_with_timeout(request(&target, "set", Some(&content)), PERMS_TIMEOUT)?;
143            writeln!(
144                output,
145                "Permissions updated for {}",
146                transport.target_path()
147            )?;
148            Ok(())
149        }
150        None => {
151            let body =
152                transport.request_with_timeout(request(&target, "get", None), PERMS_TIMEOUT)?;
153            let value = msgpack::unpack_exact(&body)
154                .map_err(|err| Error::msg(format!("invalid permissions response: {err}")))?;
155            let content = value
156                .map_get("content")
157                .and_then(Value::as_str)
158                .unwrap_or("");
159            write!(output, "{content}")?;
160            Ok(())
161        }
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166enum PermsTarget {
167    Group(String),
168    Repository(String),
169}
170
171fn target_from_path(path: &str) -> Result<PermsTarget> {
172    let trimmed = path.trim_matches('/');
173    if trimmed.is_empty() {
174        return Err(Error::msg("invalid permissions target"));
175    }
176    if trimmed.contains('/') {
177        Ok(PermsTarget::Repository(trimmed.to_string()))
178    } else {
179        Ok(PermsTarget::Group(trimmed.to_string()))
180    }
181}
182
183fn request(target: &PermsTarget, step: &str, content: Option<&str>) -> Vec<u8> {
184    let mut map = match target {
185        PermsTarget::Group(group) => vec![
186            (Value::UInt(protocol::IDX_GROUP), Value::Str(group.clone())),
187            (Value::Str("operation".into()), Value::Str("gperms".into())),
188            (Value::Str("step".into()), Value::Str(step.into())),
189        ],
190        PermsTarget::Repository(repo) => vec![
191            (
192                Value::UInt(protocol::IDX_REPOSITORY),
193                Value::Str(repo.clone()),
194            ),
195            (Value::Str("operation".into()), Value::Str("rperms".into())),
196            (Value::Str("step".into()), Value::Str(step.into())),
197        ],
198    };
199    if let Some(content) = content {
200        map.push((Value::Str("content".into()), Value::Str(content.into())));
201    }
202    msgpack::pack(&Value::Map(map))
203}
204
205fn usage() -> &'static str {
206    "usage: rngit perms [--config DIR] [--rnsconfig DIR] [-i|--identity PATH] [--content PATH] <rns://destination/group[/repo]>"
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[derive(Default)]
214    struct FakeTransport {
215        target_path: String,
216        responses: Vec<Vec<u8>>,
217        requests: Vec<Value>,
218        timeouts: Vec<Duration>,
219    }
220
221    impl PermsTransport for FakeTransport {
222        fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
223            self.request_with_timeout(data, PERMS_TIMEOUT)
224        }
225
226        fn request_with_timeout(&mut self, data: Vec<u8>, timeout: Duration) -> Result<Vec<u8>> {
227            self.requests.push(msgpack::unpack_exact(&data).unwrap());
228            self.timeouts.push(timeout);
229            Ok(self.responses.remove(0))
230        }
231
232        fn target_path(&self) -> &str {
233            &self.target_path
234        }
235    }
236
237    #[test]
238    fn parses_perms_options() {
239        let opts = PermsOptions::parse([
240            "--config".into(),
241            "cfg".into(),
242            "--rnsconfig".into(),
243            "rns".into(),
244            "--identity".into(),
245            "id".into(),
246            "--content".into(),
247            "allowed.txt".into(),
248            "rns://00112233445566778899aabbccddeeff/group/repo".into(),
249        ])
250        .unwrap();
251        assert_eq!(opts.config_dir, Some(PathBuf::from("cfg")));
252        assert_eq!(opts.rns_config_dir, Some(PathBuf::from("rns")));
253        assert_eq!(opts.identity_path, Some(PathBuf::from("id")));
254        assert_eq!(opts.content_path, Some(PathBuf::from("allowed.txt")));
255    }
256
257    #[test]
258    fn target_path_distinguishes_group_and_repository() {
259        assert_eq!(
260            target_from_path("group").unwrap(),
261            PermsTarget::Group("group".into())
262        );
263        assert_eq!(
264            target_from_path("group/repo").unwrap(),
265            PermsTarget::Repository("group/repo".into())
266        );
267    }
268
269    #[test]
270    fn get_prints_permissions_and_sends_group_request() {
271        let mut fake = FakeTransport {
272            target_path: "group".into(),
273            responses: vec![msgpack::pack(&Value::Map(vec![(
274                Value::Str("content".into()),
275                Value::Str("read = all\n".into()),
276            )]))],
277            requests: Vec::new(),
278            timeouts: Vec::new(),
279        };
280        let mut out = Vec::new();
281
282        run_perms_command(&mut fake, None, &mut out).unwrap();
283
284        assert_eq!(String::from_utf8(out).unwrap(), "read = all\n");
285        assert_eq!(fake.timeouts, vec![PERMS_TIMEOUT]);
286        assert_eq!(
287            fake.requests[0]
288                .map_get("operation")
289                .and_then(Value::as_str),
290            Some("gperms")
291        );
292        assert_eq!(
293            fake.requests[0].map_get("step").and_then(Value::as_str),
294            Some("get")
295        );
296    }
297
298    #[test]
299    fn set_reads_content_and_sends_repository_request() {
300        let tmp = tempfile::tempdir().unwrap();
301        let path = tmp.path().join("repo.allowed");
302        fs::write(&path, "write = all\n").unwrap();
303        let mut fake = FakeTransport {
304            target_path: "group/repo".into(),
305            responses: vec![Vec::new()],
306            requests: Vec::new(),
307            timeouts: Vec::new(),
308        };
309        let mut out = Vec::new();
310
311        run_perms_command(&mut fake, Some(&path), &mut out).unwrap();
312
313        assert_eq!(
314            fake.requests[0]
315                .map_get("operation")
316                .and_then(Value::as_str),
317            Some("rperms")
318        );
319        assert_eq!(
320            fake.requests[0].map_get("content").and_then(Value::as_str),
321            Some("write = all\n")
322        );
323        assert_eq!(
324            String::from_utf8(out).unwrap(),
325            "Permissions updated for group/repo\n"
326        );
327    }
328}