1use std::collections::HashMap;
11use std::fmt;
12use std::fs;
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex, MutexGuard};
16
17#[derive(Clone, PartialEq, Eq)]
19pub struct Record {
20 pub serial: String,
21 pub socket: String,
22 pub pid: u32,
23 pub token: String,
25 pub agent: String,
27 pub protocol: u16,
29 pub build: String,
32}
33
34impl fmt::Debug for Record {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.debug_struct("Record")
38 .field("serial", &self.serial)
39 .field("socket", &self.socket)
40 .field("pid", &self.pid)
41 .field("token", &"<redacted>")
42 .field("agent", &self.agent)
43 .field("protocol", &self.protocol)
44 .field("build", &self.build)
45 .finish()
46 }
47}
48
49impl Record {
50 pub fn to_text(&self) -> String {
52 format!(
53 "serial={}\nsocket={}\npid={}\ntoken={}\nagent={}\nprotocol={}\nbuild={}\n",
54 self.serial, self.socket, self.pid, self.token, self.agent, self.protocol, self.build
55 )
56 }
57
58 pub fn from_text(text: &str) -> Option<Record> {
60 let fields: HashMap<&str, &str> = text
61 .lines()
62 .filter_map(|line| line.split_once('='))
63 .collect();
64 let get = |key: &str| fields.get(key).copied().filter(|v| !v.is_empty());
65 Some(Record {
66 serial: get("serial")?.to_owned(),
67 socket: get("socket")?.to_owned(),
68 pid: get("pid")?.parse().ok()?,
69 token: get("token")?.to_owned(),
70 agent: get("agent")?.to_owned(),
71 protocol: get("protocol")?.parse().ok()?,
72 build: get("build").unwrap_or_default().to_owned(),
73 })
74 }
75}
76
77#[derive(Clone, Debug)]
79pub struct Records {
80 memory: Arc<Mutex<HashMap<String, Record>>>,
81 dir: Option<PathBuf>,
82}
83
84impl Records {
85 pub fn in_memory() -> Records {
87 Records {
88 memory: Arc::new(Mutex::new(HashMap::new())),
89 dir: None,
90 }
91 }
92
93 pub fn in_dir(dir: &Path) -> io::Result<Records> {
96 if !dir.is_dir() {
97 fs::create_dir_all(dir)?;
98 #[cfg(unix)]
99 {
100 use std::os::unix::fs::PermissionsExt;
101 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
102 }
103 }
104 Ok(Records {
105 memory: Arc::new(Mutex::new(HashMap::new())),
106 dir: Some(dir.to_owned()),
107 })
108 }
109
110 fn memory(&self) -> MutexGuard<'_, HashMap<String, Record>> {
111 self.memory.lock().unwrap_or_else(|e| e.into_inner())
114 }
115
116 fn file(&self, serial: &str) -> Option<PathBuf> {
117 self.dir
118 .as_ref()
119 .map(|dir| dir.join(Records::file_name(serial)))
120 }
121
122 pub fn dir(&self) -> Option<&Path> {
124 self.dir.as_deref()
125 }
126
127 pub fn get(&self, serial: &str) -> Option<Record> {
130 if let Some(record) = self.memory().get(serial) {
131 return Some(record.clone());
132 }
133 let text = fs::read_to_string(self.file(serial)?).ok()?;
134 let record = Record::from_text(&text).filter(|r| r.serial == serial)?;
135 self.memory().insert(serial.to_owned(), record.clone());
136 Some(record)
137 }
138
139 pub fn put(&self, record: Record) -> io::Result<()> {
141 if let Some(path) = self.file(&record.serial) {
142 let partial = path.with_extension("agent.partial");
144 let mut options = fs::OpenOptions::new();
145 options.write(true).create(true).truncate(true);
146 #[cfg(unix)]
147 {
148 use std::os::unix::fs::OpenOptionsExt;
149 options.mode(0o600);
150 }
151 let mut file = options.open(&partial)?;
152 file.write_all(record.to_text().as_bytes())?;
153 file.sync_all()?;
154 fs::rename(&partial, &path)?;
155 }
156 self.memory().insert(record.serial.clone(), record);
157 Ok(())
158 }
159
160 pub fn remove(&self, serial: &str) -> io::Result<()> {
162 self.memory().remove(serial);
163 match self.file(serial).map(fs::remove_file) {
164 Some(Err(e)) if e.kind() != io::ErrorKind::NotFound => Err(e),
165 _ => Ok(()),
166 }
167 }
168
169 pub fn file_name(serial: &str) -> String {
172 let safe: String = serial
173 .chars()
174 .map(|c| {
175 if c.is_ascii_alphanumeric() || "._-".contains(c) {
176 c
177 } else {
178 '_'
179 }
180 })
181 .collect();
182 format!("{safe}.agent")
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn record(serial: &str, pid: u32) -> Record {
191 Record {
192 serial: serial.into(),
193 socket: "mobile-agent-0.1.0-p1-00ff".into(),
194 pid,
195 token: "0123456789abcdef".into(),
196 agent: "mobile-agent-android 0.1.0".into(),
197 protocol: 1,
198 build: "0123456789ab".into(),
199 }
200 }
201
202 fn scratch(tag: &str) -> PathBuf {
203 let dir = std::env::temp_dir().join(format!(
204 "android-records-{tag}-{}-{}",
205 std::process::id(),
206 random_suffix()
207 ));
208 let _ = std::fs::remove_dir_all(&dir);
209 dir
210 }
211
212 fn random_suffix() -> u64 {
213 use std::hash::{BuildHasher, Hasher};
214 std::collections::hash_map::RandomState::new()
215 .build_hasher()
216 .finish()
217 }
218
219 #[test]
220 fn a_record_round_trips_through_its_text() {
221 let r = record("emulator-5554", 812);
222 assert_eq!(Record::from_text(&r.to_text()), Some(r.clone()));
223 assert_eq!(Record::from_text("serial=x\nsocket=y\n"), None);
224 assert_eq!(
225 Record::from_text(&r.to_text().replace("pid=812", "pid=x")),
226 None
227 );
228 }
229
230 #[test]
231 fn a_record_written_before_builds_were_recorded_has_an_unknown_build() {
232 let old = "serial=emulator-5554\nsocket=mobile-agent-0.1.0-p1-00ff\npid=812\n\
233 token=0123456789abcdef\nagent=mobile-agent-android 0.1.0\nprotocol=1\n";
234 let record = Record::from_text(old).unwrap();
235 assert_eq!(record.build, "");
236 assert_eq!(record.pid, 812);
237 }
238
239 #[test]
240 fn debug_output_never_shows_the_token() {
241 let text = format!("{:?}", record("emulator-5554", 1));
242 assert!(!text.contains("0123456789abcdef"), "{text}");
243 assert!(text.contains("<redacted>"), "{text}");
244 }
245
246 #[test]
247 fn records_in_memory_are_shared_by_clones_and_forgotten_on_remove() {
248 let records = Records::in_memory();
249 let clone = records.clone();
250 records.put(record("emulator-5554", 812)).unwrap();
251 assert_eq!(clone.get("emulator-5554").map(|r| r.pid), Some(812));
252 assert_eq!(clone.get("emulator-5556"), None);
253 clone.put(record("emulator-5554", 900)).unwrap();
254 assert_eq!(records.get("emulator-5554").map(|r| r.pid), Some(900));
255 records.remove("emulator-5554").unwrap();
256 assert_eq!(clone.get("emulator-5554"), None);
257 assert!(records.dir().is_none());
258 }
259
260 #[test]
261 fn records_in_a_directory_outlive_the_process_that_wrote_them() {
262 let dir = scratch("persist");
263 let first = Records::in_dir(&dir).unwrap();
264 first.put(record("192.168.1.5:5555", 812)).unwrap();
265 let second = Records::in_dir(&dir).unwrap();
267 assert_eq!(
268 second.get("192.168.1.5:5555"),
269 Some(record("192.168.1.5:5555", 812))
270 );
271 second.remove("192.168.1.5:5555").unwrap();
272 assert_eq!(Records::in_dir(&dir).unwrap().get("192.168.1.5:5555"), None);
273 std::fs::remove_dir_all(&dir).unwrap();
274 }
275
276 #[cfg(unix)]
277 #[test]
278 fn record_files_are_readable_by_their_owner_only() {
279 use std::os::unix::fs::PermissionsExt;
280 let dir = scratch("mode");
281 let records = Records::in_dir(&dir).unwrap();
282 records.put(record("emulator-5554", 812)).unwrap();
283 let file = dir.join(Records::file_name("emulator-5554"));
284 let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
285 assert_eq!(mode, 0o600);
286 let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
287 assert_eq!(dir_mode, 0o700);
288 std::fs::remove_dir_all(&dir).unwrap();
289 }
290
291 #[test]
292 fn a_corrupt_record_file_is_ignored() {
293 let dir = scratch("corrupt");
294 let records = Records::in_dir(&dir).unwrap();
295 std::fs::write(dir.join(Records::file_name("emulator-5554")), "garbage").unwrap();
296 assert_eq!(records.get("emulator-5554"), None);
297 std::fs::remove_dir_all(&dir).unwrap();
298 }
299
300 #[test]
301 fn serials_become_safe_file_names() {
302 assert_eq!(Records::file_name("emulator-5554"), "emulator-5554.agent");
303 assert_eq!(
304 Records::file_name("192.168.1.5:5555"),
305 "192.168.1.5_5555.agent"
306 );
307 assert_eq!(Records::file_name("../x/y"), ".._x_y.agent");
308 }
309}