1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//! Paired devices, one per line: `<64 hex key> <unix seconds> <name>` (SPEC.md §5).
use std::io;
use std::path::{Path, PathBuf};
/// Most peers a store keeps; adding past it drops the oldest (SPEC.md §5 item 7).
pub const MAX_PEERS: usize = 32;
/// One paired device.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
/// Its static X25519 public key.
pub key: [u8; 32],
/// When it was added, in Unix seconds.
pub added_unix: u64,
/// Its name as stored. [`PeerStore::add`] keeps one line of at most 63 bytes, control
/// characters replaced by spaces.
pub name: String,
}
/// The paired devices, in a file (or in memory). A change re-reads the file under an
/// advisory lock first, so processes sharing it do not undo each other (SPEC.md §5 item 7).
#[derive(Debug, Default)]
pub struct PeerStore {
path: Option<PathBuf>,
peers: Vec<Peer>,
/// The file exists but could not be read: nothing is known, and nothing is ever
/// written over it (that would unpair every device it lists).
unreadable: Option<String>,
/// Tokens this side stamped into helpers it launched (SPEC.md §5.2): a server that
/// proves one is stored without a pairing code.
launch: Option<crate::launch::LaunchTokens>,
}
impl PeerStore {
/// Not saved anywhere (tests, one-off tools).
pub fn in_memory() -> Self {
Self::default()
}
/// `load`, or — when the file exists but cannot be read — a store that knows no one,
/// never writes the file, and reports why through `unreadable()`. A server keeps
/// running on it but must not open its automatic pairing window (SPEC.md §5 item 7).
pub fn load_or_unreadable(path: &Path) -> Self {
Self::load(path).unwrap_or_else(|e| Self {
path: Some(path.to_path_buf()),
peers: Vec::new(),
unreadable: Some(format!("{}: {e}", path.display())),
launch: None,
})
}
/// Why the peers file could not be read, if it could not.
pub fn unreadable(&self) -> Option<&str> {
self.unreadable.as_deref()
}
/// A missing file is an empty store; unreadable lines are skipped, never fatal.
pub fn load(path: &Path) -> io::Result<Self> {
let peers = match std::fs::read(path) {
Ok(b) => String::from_utf8_lossy(&b)
.lines()
.filter_map(parse_line)
.take(MAX_PEERS)
.collect(),
Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e),
};
Ok(Self {
path: Some(path.to_path_buf()),
peers,
unreadable: None,
launch: None,
})
}
/// Recognise servers launched with one of these tokens (SPEC.md §5.2).
pub fn with_launch_tokens(mut self, t: crate::launch::LaunchTokens) -> Self {
self.launch = Some(t);
self
}
/// Whether a Welcome's `launch_proof` on handshake `h` was made with a token this
/// side issued and that has not expired.
pub fn launched_by_us(&self, h: &[u8; 64], proof: &[u8; 16]) -> bool {
self.launch.as_ref().is_some_and(|t| t.recognises(h, proof))
}
/// Whether `key` is a paired device, as of the last load or change.
pub fn contains(&self, key: &[u8; 32]) -> bool {
self.peers.iter().any(|p| &p.key == key)
}
/// The paired devices, oldest first.
pub fn list(&self) -> &[Peer] {
&self.peers
}
fn refuse_if_unreadable(&self) -> io::Result<()> {
match &self.unreadable {
Some(why) => Err(io::Error::other(format!(
"the peers file could not be read ({why}); not overwriting it"
))),
None => Ok(()),
}
}
/// Adds or replaces `key`; drops the oldest peer past `MAX_PEERS`; saves. Nothing
/// changes unless the save succeeds.
pub fn add(&mut self, key: [u8; 32], name: &str) -> io::Result<()> {
self.refuse_if_unreadable()?;
// Under the file's lock, from what is on disk now: another process may have paired a
// device since this store was loaded, and saving the stale copy would unpair it.
let _lock = self.lock_and_reload()?;
let before = self.peers.clone();
self.peers.retain(|p| p.key != key);
if self.peers.len() >= MAX_PEERS {
self.peers.remove(0);
}
let added_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.peers.push(Peer {
key,
added_unix,
name: clean_name(name),
});
self.save().inspect_err(|_| self.peers = before)
}
/// Forgets `key`; saves. Whether it was there. Nothing changes unless the save
/// succeeds.
pub fn remove(&mut self, key: &[u8; 32]) -> io::Result<bool> {
self.refuse_if_unreadable()?;
let _lock = self.lock_and_reload()?;
let before = self.peers.clone();
self.peers.retain(|p| &p.key != key);
if self.peers.len() == before.len() {
return Ok(false);
}
self.save().inspect_err(|_| self.peers = before)?;
Ok(true)
}
/// Takes the file's advisory lock (none for an in-memory store) and re-reads the peers
/// from disk, so a read-modify-write starts from what every process has written.
fn lock_and_reload(&mut self) -> io::Result<Option<crate::fslock::FileLock>> {
let Some(path) = self.path.clone() else {
return Ok(None);
};
let lock = crate::fslock::lock(&path)?;
self.peers = Self::load(&path)?.peers;
Ok(Some(lock))
}
/// Writes the file; the caller holds the lock.
fn save(&self) -> io::Result<()> {
let Some(path) = &self.path else {
return Ok(());
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text: String = self
.peers
.iter()
.map(|p| {
format!(
"{} {} {}\n",
crate::hex::encode(&p.key),
p.added_unix,
p.name
)
})
.collect();
let tmp = crate::fslock::TmpGuard::new(crate::fslock::unique_tmp(path));
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(tmp.path())?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(tmp.path(), path)?;
tmp.disarm();
Ok(())
}
}
/// One line, no control characters, at most 63 bytes on a char boundary.
fn clean_name(n: &str) -> String {
let mut s = String::new();
for c in n.chars().map(|c| if c.is_control() { ' ' } else { c }) {
if s.len() + c.len_utf8() > 63 {
break;
}
s.push(c);
}
s
}
fn parse_line(l: &str) -> Option<Peer> {
let mut it = l.splitn(3, ' ');
let key: [u8; 32] = crate::hex::decode(it.next()?)?.try_into().ok()?;
let added_unix = it.next()?.parse().ok()?;
let name = it.next().unwrap_or("").to_string();
Some(Peer {
key,
added_unix,
name,
})
}