Skip to main content

android/agent/
records.rs

1//! What this host remembers about the agents it started: socket, pid, token
2//! and versions per serial. The token proves that an agent answering on a
3//! socket is the one this host started, so only a recorded agent is reused;
4//! any other agent is another holder of UI automation.
5//!
6//! Records live in memory, and also in a directory when the daemon names
7//! one, so that a restarted daemon reuses the agent its predecessor left
8//! running. Each record is a file readable by its owner only.
9
10use 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/// One agent this host started.
18#[derive(Clone, PartialEq, Eq)]
19pub struct Record {
20    pub serial: String,
21    pub socket: String,
22    pub pid: u32,
23    /// The per-launch token its hello carries.
24    pub token: String,
25    /// The agent name and version its hello carried.
26    pub agent: String,
27    /// The protocol version negotiated with it.
28    pub protocol: u16,
29    /// The build it was started from; empty in a record written before
30    /// builds were recorded.
31    pub build: String,
32}
33
34impl fmt::Debug for Record {
35    /// Shows whether a token is present, never the token.
36    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    /// The record as the lines of its file: `key=value`.
51    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    /// Parses [`Record::to_text`]; `None` when a field is missing or invalid.
59    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/// The records of one host, shared by every clone.
78#[derive(Clone, Debug)]
79pub struct Records {
80    memory: Arc<Mutex<HashMap<String, Record>>>,
81    dir: Option<PathBuf>,
82}
83
84impl Records {
85    /// Records that last as long as this process.
86    pub fn in_memory() -> Records {
87        Records {
88            memory: Arc::new(Mutex::new(HashMap::new())),
89            dir: None,
90        }
91    }
92
93    /// Records also kept as files in `dir`, created readable by its owner
94    /// only when missing.
95    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        // A panic while holding the map leaves it consistent: every
112        // operation is a single insert or remove.
113        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    /// The directory, when records are kept in one.
123    pub fn dir(&self) -> Option<&Path> {
124        self.dir.as_deref()
125    }
126
127    /// The record for `serial`: the one in memory, else the one in the
128    /// directory.
129    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    /// Stores `record`, replacing any record for its serial.
140    pub fn put(&self, record: Record) -> io::Result<()> {
141        if let Some(path) = self.file(&record.serial) {
142            // Written aside and renamed, so a reader never sees half a record.
143            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    /// Forgets the record for `serial`.
161    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    /// The file name of `serial`'s record: the serial with every character
170    /// other than letters, digits, `.`, `_` and `-` replaced, then `.agent`.
171    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        // A restarted daemon: nothing in memory, the file remains.
266        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}