Skip to main content

netget/client/
media_root.rs

1//! The one directory a media client may read a local file from.
2//!
3//! `srt_send_file` and `rtmp_publish` take the path of a file on this machine and stream its
4//! bytes to the peer — which is the same peer whose responses the model reads, so a
5//! prompt-injected `{"path": "/home/op/.ssh/id_ed25519"}` was a file-exfiltration primitive.
6//! The content gates (SRT checked one byte, `0x47`; RTMP an `FLV\x01` magic) are format
7//! sniffing, not confinement: a 64 MiB read of any file the process can open, shipped raw.
8//!
9//! A [`MediaRoot`] is the client's boundary, set by the `media_root` startup parameter and
10//! defaulting to a NetGet-owned directory under the platform's local-data dir — the same
11//! reasoning as the Git client's `sandbox::default_root`: neither `$HOME` nor the working
12//! directory, which are exactly what is being protected. A path is canonicalised (the file
13//! has to exist to be read) and must sit under the root and be a regular file.
14
15use std::path::{Path, PathBuf};
16
17use anyhow::{bail, Context, Result};
18
19/// Startup parameter naming the directory.
20pub const MEDIA_ROOT_PARAM: &str = "media_root";
21
22/// Where files come from when no `media_root` is configured.
23pub fn default_root() -> PathBuf {
24    if let Some(data) = dirs::data_local_dir() {
25        return data.join("netget").join("media");
26    }
27    if let Some(home) = dirs::home_dir() {
28        return home.join(".netget-media");
29    }
30    std::env::temp_dir().join("netget-media")
31}
32
33/// One client's media boundary: a canonical directory that exists.
34#[derive(Debug, Clone)]
35pub struct MediaRoot {
36    root: PathBuf,
37}
38
39impl MediaRoot {
40    /// Build the boundary from the `media_root` startup parameter, creating the directory
41    /// so the default is usable without preparation.
42    pub fn new(configured: Option<&str>) -> Result<Self> {
43        let requested = match configured {
44            Some(raw) if !raw.trim().is_empty() => PathBuf::from(raw.trim()),
45            _ => default_root(),
46        };
47        std::fs::create_dir_all(&requested).with_context(|| {
48            format!(
49                "could not create the {MEDIA_ROOT_PARAM} directory {}",
50                requested.display()
51            )
52        })?;
53        let root = requested.canonicalize().with_context(|| {
54            format!(
55                "could not canonicalise {MEDIA_ROOT_PARAM} {}",
56                requested.display()
57            )
58        })?;
59        if !root.is_dir() {
60            bail!("{MEDIA_ROOT_PARAM} ({}) is not a directory", root.display());
61        }
62        Ok(Self { root })
63    }
64
65    /// The canonical root.
66    pub fn root(&self) -> &Path {
67        &self.root
68    }
69
70    /// The file `raw` names, if it is a regular file under the root.
71    ///
72    /// A relative path is resolved against the root, not the working directory. The
73    /// canonical form is checked, so a symlink under the root pointing outside it is
74    /// refused too. Refused rather than relocated, naming the parameter that widens it.
75    pub fn resolve(&self, raw: &str, context: &str) -> Result<PathBuf> {
76        let trimmed = raw.trim();
77        if trimmed.is_empty() {
78            bail!("{context} is empty");
79        }
80        let requested = Path::new(trimmed);
81        let absolute = if requested.is_absolute() {
82            requested.to_path_buf()
83        } else {
84            self.root.join(requested)
85        };
86        let resolved = absolute
87            .canonicalize()
88            .with_context(|| format!("{context} ({trimmed}) does not exist"))?;
89        if !resolved.starts_with(&self.root) {
90            bail!(
91                "{context} ({trimmed}) resolves to {} which is outside this client's \
92                 {MEDIA_ROOT_PARAM} ({}). Only files under that directory are read; put the \
93                 file there, or set the `{MEDIA_ROOT_PARAM}` startup parameter to a directory \
94                 that contains it.",
95                resolved.display(),
96                self.root.display()
97            );
98        }
99        if !resolved.is_file() {
100            bail!("{context} ({trimmed}) is not a regular file");
101        }
102        Ok(resolved)
103    }
104}