Skip to main content

kopiur_kopia/
session.rs

1//! The closed command surface of a browse session.
2//!
3//! A `kubectl kopiur browse` session pod connects to its repository
4//! **read-only** ([`crate::KopiaClient::repository_connect_readonly`]) and then
5//! only ever executes commands rendered by [`SessionCmd`]. The enum is closed
6//! and every transport (the CLI's pod-exec path) builds argv exclusively
7//! through [`SessionCmd::argv`], so a mutating kopia verb is *structurally*
8//! impossible — the type system, not a denylist, is the guarantee (ADR §5.5).
9
10/// A kopia object id as it appears in manifests. Validated so a repository
11/// entry can never smuggle a flag onto the session argv.
12///
13/// kopia's id space is wider than plain hex: content ids (`k…`/`x…`), indirect
14/// ids (`Ix…`), and *section* ids (`S<start>,<length>,<base>`) all appear in
15/// real manifests, and entry names carry through into ids that hold `-`, `_`
16/// or `.`. The gate is therefore a **deny-list**, not an allow-list: the only
17/// threat is argv flag injection (the session never goes through a shell —
18/// [`SessionCmd::argv`] is exec'd directly), so a legitimate-but-unforeseen id
19/// form must not be rejected.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ObjectId(String);
22
23/// A string that is not a well-formed kopia object id, naming the value and the
24/// rule it broke.
25#[derive(Debug, thiserror::Error, PartialEq, Eq)]
26#[error("invalid kopia object id {value:?}: {reason}")]
27pub struct InvalidObjectId {
28    /// The rejected value, verbatim.
29    pub value: String,
30    /// Which rule it broke (e.g. `must not start with '-'`).
31    pub reason: &'static str,
32}
33
34impl ObjectId {
35    /// Parse an object id read out of a repository manifest, rejecting only
36    /// what could subvert the session's argv or its logs: an empty string, a
37    /// leading `-` (kopia would read the id as a flag), a `/` (a path, never an
38    /// id), whitespace (argv word-splitting in anything that re-renders the
39    /// command), and control/non-ASCII bytes (terminal escapes in error text).
40    /// Everything else — commas, dashes, dots, underscores — is a shape kopia
41    /// itself emits and passes through.
42    pub fn parse(s: &str) -> Result<Self, InvalidObjectId> {
43        let reject = |reason: &'static str| {
44            Err(InvalidObjectId {
45                value: s.to_string(),
46                reason,
47            })
48        };
49        if s.is_empty() {
50            return reject("must not be empty");
51        }
52        if s.starts_with('-') {
53            return reject("must not start with '-', which kopia would read as a flag");
54        }
55        if s.contains('/') {
56            return reject("must not contain '/'");
57        }
58        if s.bytes().any(|b| b.is_ascii_whitespace()) {
59            return reject("must not contain whitespace");
60        }
61        if s.bytes().any(|b| !b.is_ascii() || b.is_ascii_control()) {
62            return reject("must be printable ASCII");
63        }
64        Ok(Self(s.to_string()))
65    }
66
67    /// The validated id, for rendering onto argv.
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73/// The ONLY kopia invocations a browse session may issue. Closed enum — the
74/// CLI/exec transport renders argv exclusively through this, so a mutating
75/// verb is structurally impossible. A new variant cannot compile until
76/// [`SessionCmd::argv`] (and its read-only test) accounts for it.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum SessionCmd {
79    /// `kopia snapshot list --json --all`: the repository's full snapshot
80    /// catalog (every user@host identity), parsed as
81    /// [`Vec<SnapshotListEntry>`](crate::SnapshotListEntry).
82    SnapshotListJson,
83    /// `kopia show <oid>`: a directory object's manifest
84    /// ([`DirManifest`](crate::DirManifest) JSON) or a file object's raw bytes.
85    ShowObject {
86        /// The kopia object id to show (a directory's manifest or a file's
87        /// content stream).
88        oid: ObjectId,
89    },
90}
91
92impl SessionCmd {
93    /// Render the full argv (binary first) for this command. Exhaustive
94    /// `match`: a new session command must decide its argv here to compile.
95    pub fn argv(&self, kopia_bin: &str) -> Vec<String> {
96        match self {
97            SessionCmd::SnapshotListJson => vec![
98                kopia_bin.to_string(),
99                "snapshot".to_string(),
100                "list".to_string(),
101                "--json".to_string(),
102                "--all".to_string(),
103            ],
104            SessionCmd::ShowObject { oid } => {
105                vec![
106                    kopia_bin.to_string(),
107                    "show".to_string(),
108                    oid.as_str().to_string(),
109                ]
110            }
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    /// One representative of every variant, so the read-only sweep below cannot
120    /// silently skip a new command.
121    fn all_commands() -> Vec<SessionCmd> {
122        vec![
123            SessionCmd::SnapshotListJson,
124            SessionCmd::ShowObject {
125                oid: ObjectId::parse("kdeadbeef").expect("valid oid"),
126            },
127        ]
128    }
129
130    #[test]
131    fn snapshot_list_renders_the_exact_argv() {
132        assert_eq!(
133            SessionCmd::SnapshotListJson.argv("/usr/local/bin/kopia"),
134            vec![
135                "/usr/local/bin/kopia",
136                "snapshot",
137                "list",
138                "--json",
139                "--all"
140            ]
141        );
142    }
143
144    #[test]
145    fn show_object_renders_the_exact_argv() {
146        let cmd = SessionCmd::ShowObject {
147            oid: ObjectId::parse("k9c0ffee").expect("valid oid"),
148        };
149        assert_eq!(
150            cmd.argv("/usr/local/bin/kopia"),
151            vec!["/usr/local/bin/kopia", "show", "k9c0ffee"]
152        );
153    }
154
155    #[test]
156    fn object_id_accepts_every_shape_kopia_actually_emits() {
157        // Plain content id, a *section* id (commas), a manifest entry whose id
158        // carries a dash, and an indirect id — all real kopia forms. An
159        // allow-list of alphanumerics would false-reject the last three and
160        // break browsing on real repositories.
161        // `k;rm` is accepted deliberately: a shell metacharacter is not a
162        // threat here — argv is exec'd directly as a list (no shell anywhere on
163        // the session path), so the leading-flag prefix is the only injection
164        // vector, and an allow-list that rejected `;` would also have to guess
165        // which of kopia's id shapes are legal.
166        for good in ["k1a2b3", "S12,34,kabc", "kfile-a", "Ixdeadbeef", "k;rm"] {
167            let oid = ObjectId::parse(good).unwrap_or_else(|e| panic!("{good:?} must parse: {e}"));
168            assert_eq!(oid.as_str(), good);
169        }
170    }
171
172    #[test]
173    fn object_id_rejects_anything_that_could_smuggle_a_flag() {
174        // Every rejection names the value and the rule it broke.
175        for (bad, needle) in [
176            ("", "must not be empty"),
177            ("-h", "must not start with '-'"),
178            ("--config-file=/x", "must not start with '-'"),
179            ("a b", "must not contain whitespace"),
180            ("a\nb", "must not contain whitespace"),
181            ("a/b", "must not contain '/'"),
182            ("k/../x", "must not contain '/'"),
183            ("k\u{7f}rm", "must be printable ASCII"),
184        ] {
185            let err =
186                ObjectId::parse(bad).expect_err(&format!("{bad:?} must not parse as an object id"));
187            assert_eq!(err.value, bad);
188            let msg = err.to_string();
189            assert!(msg.contains(needle), "{bad:?}: {msg}");
190            assert!(msg.contains(&format!("{bad:?}")), "{bad:?}: {msg}");
191        }
192    }
193
194    #[test]
195    fn no_variant_renders_a_mutating_verb() {
196        // The structural guarantee, asserted: no session command's argv may
197        // ever contain a kopia verb that can change the repository.
198        const MUTATING: &[&str] = &[
199            "delete",
200            "create",
201            "set",
202            "policy",
203            "maintenance",
204            "restore",
205        ];
206        for cmd in all_commands() {
207            let argv = cmd.argv("kopia");
208            for verb in MUTATING {
209                assert!(
210                    !argv.iter().any(|a| a == verb),
211                    "{cmd:?} renders mutating verb {verb}: {argv:?}"
212                );
213            }
214        }
215    }
216}