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}