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}