|
1
|
+ |
//! Concurrent writers to one config directory.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! Every private file this CLI keeps — the credential store, the pending
|
|
4
|
+ |
//! device authorizations, the identity seed, `computer.json` — is written by a
|
|
5
|
+ |
//! process that has no claim on the machine. A fleet of agents runs under one
|
|
6
|
+ |
//! `$HOME` by design: the delegation engine starts children and each one
|
|
7
|
+ |
//! carries a credential. Two `oa auth login` runs at once are the same thing at
|
|
8
|
+ |
//! human speed.
|
|
9
|
+ |
//!
|
|
10
|
+ |
//! These writes used to stage through a name derived only from the target —
|
|
11
|
+ |
//! `path.with_extension("tmp")` — so every writer of a given file shared one
|
|
12
|
+ |
//! staging path. They truncated and renamed each other's half-written bytes:
|
|
13
|
+ |
//! one won, and the other's `rename` found nothing and reported a failed
|
|
14
|
+ |
//! credential write for a credential that may well have been stored. A caller
|
|
15
|
+ |
//! that cannot tell whether its own token landed has no move left.
|
|
16
|
+ |
//!
|
|
17
|
+ |
//! So each test here runs N writers against one directory and demands two
|
|
18
|
+ |
//! things a shared staging name cannot give: **every writer reports success**,
|
|
19
|
+ |
//! and **the file left behind is whole**. `computer.json` had it worse still —
|
|
20
|
+ |
//! it wrote in place, with no staging at all — so its test adds concurrent
|
|
21
|
+ |
//! readers, which is what makes the truncate window visible. Put any of these
|
|
22
|
+ |
//! writers back the way it was and the matching test fails.
|
|
23
|
+ |
|
|
24
|
+ |
use openagents_cli::auth::{CredentialStore, PendingDeviceAuthorization, PendingStore, Secret};
|
|
25
|
+ |
use openagents_cli::computer::{ComputerPaths, PolicyConfig};
|
|
26
|
+ |
use openagents_cli::identity::{generate_seed_phrase, NoKeyStore, SeedStore};
|
|
27
|
+ |
use std::io::{BufRead, BufReader, Read, Write};
|
|
28
|
+ |
use std::net::TcpListener;
|
|
29
|
+ |
use std::path::Path;
|
|
30
|
+ |
use std::process::Command;
|
|
31
|
+ |
use std::thread;
|
|
32
|
+ |
|
|
33
|
+ |
/// Enough writers to overlap on any machine that runs this, and few enough
|
|
34
|
+ |
/// that spawning them as processes stays quick.
|
|
35
|
+ |
const WRITERS: usize = 12;
|
|
36
|
+ |
|
|
37
|
+ |
// ---------------------------------------------------------------------------
|
|
38
|
+ |
// the reported failure: separate `oa` processes, one config directory
|
|
39
|
+ |
// ---------------------------------------------------------------------------
|
|
40
|
+ |
|
|
41
|
+ |
/// A server that answers every request with one canned device authorization.
|
|
42
|
+ |
///
|
|
43
|
+ |
/// `oa auth login --headless` starts an authorization, prints the code, writes
|
|
44
|
+ |
/// it to `device-authorizations.json`, and exits — no polling, so a run of it
|
|
45
|
+ |
/// is a short process whose only side effect is that write. That makes it the
|
|
46
|
+ |
/// honest way to put N real `oa` processes on one file at once.
|
|
47
|
+ |
fn stub_authorization_server() -> String {
|
|
48
|
+ |
const BODY: &str = r#"{"device_code":"d-race","user_code":"AAAA-BBBB",
|
|
49
|
+ |
"verification_uri":"https://example.test/device",
|
|
50
|
+ |
"verification_uri_complete":"https://example.test/device?user_code=AAAA-BBBB",
|
|
51
|
+ |
"expires_in":600,"interval":5,"scope":"forge:write"}"#;
|
|
52
|
+ |
let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
|
|
53
|
+ |
let port = listener.local_addr().expect("read the port").port();
|
|
54
|
+ |
thread::spawn(move || {
|
|
55
|
+ |
for stream in listener.incoming() {
|
|
56
|
+ |
let Ok(mut stream) = stream else { break };
|
|
57
|
+ |
thread::spawn(move || {
|
|
58
|
+ |
let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
|
|
59
|
+ |
let mut line = String::new();
|
|
60
|
+ |
if reader.read_line(&mut line).is_err() {
|
|
61
|
+ |
return;
|
|
62
|
+ |
}
|
|
63
|
+ |
let mut length = 0usize;
|
|
64
|
+ |
loop {
|
|
65
|
+ |
let mut header = String::new();
|
|
66
|
+ |
if reader.read_line(&mut header).unwrap_or(0) == 0 || header.trim().is_empty() {
|
|
67
|
+ |
break;
|
|
68
|
+ |
}
|
|
69
|
+ |
if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
|
|
70
|
+ |
length = value.trim().parse().unwrap_or(0);
|
|
71
|
+ |
}
|
|
72
|
+ |
}
|
|
73
|
+ |
if length > 0 {
|
|
74
|
+ |
let mut discard = vec![0u8; length];
|
|
75
|
+ |
let _ = reader.read_exact(&mut discard);
|
|
76
|
+ |
}
|
|
77
|
+ |
let response = format!(
|
|
78
|
+ |
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{BODY}",
|
|
79
|
+ |
BODY.len()
|
|
80
|
+ |
);
|
|
81
|
+ |
let _ = stream.write_all(response.as_bytes());
|
|
82
|
+ |
let _ = stream.flush();
|
|
83
|
+ |
});
|
|
84
|
+ |
}
|
|
85
|
+ |
});
|
|
86
|
+ |
format!("http://127.0.0.1:{port}")
|
|
87
|
+ |
}
|
|
88
|
+ |
|
|
89
|
+ |
/// N `oa` processes sharing a config directory each store their authorization,
|
|
90
|
+ |
/// and none of them is told the write failed.
|
|
91
|
+ |
///
|
|
92
|
+ |
/// This is the reported bug end to end. With the staging name derived only
|
|
93
|
+ |
/// from the target, running only the two scope tests in `tests/flags.rs` —
|
|
94
|
+ |
/// which is enough to make two `oa` runs overlap — failed 29 times in 30 with
|
|
95
|
+ |
/// `could not write .../device-authorizations.json: No such file or directory`.
|
|
96
|
+ |
/// Twelve deliberate writers make that certain rather than likely.
|
|
97
|
+ |
#[test]
|
|
98
|
+ |
fn concurrent_oa_processes_all_record_their_authorization() {
|
|
99
|
+ |
let home = tempfile::tempdir().expect("a home of this test's own");
|
|
100
|
+ |
let origin = stub_authorization_server();
|
|
101
|
+ |
|
|
102
|
+ |
let runs: Vec<_> = (0..WRITERS)
|
|
103
|
+ |
.map(|_| {
|
|
104
|
+ |
let home = home.path().to_path_buf();
|
|
105
|
+ |
let origin = origin.clone();
|
|
106
|
+ |
thread::spawn(move || {
|
|
107
|
+ |
Command::new(env!("CARGO_BIN_EXE_oa"))
|
|
108
|
+ |
.args(["--api-url", &origin, "auth", "login", "--headless"])
|
|
109
|
+ |
.env("NO_COLOR", "")
|
|
110
|
+ |
.env("HOME", &home)
|
|
111
|
+ |
.output()
|
|
112
|
+ |
.expect("run oa")
|
|
113
|
+ |
})
|
|
114
|
+ |
})
|
|
115
|
+ |
.collect();
|
|
116
|
+ |
|
|
117
|
+ |
for (index, run) in runs.into_iter().enumerate() {
|
|
118
|
+ |
let output = run.join().expect("an oa process finished");
|
|
119
|
+ |
assert_eq!(
|
|
120
|
+ |
output.status.code(),
|
|
121
|
+ |
Some(0),
|
|
122
|
+ |
"writer {index} was told its authorization was not stored: {}",
|
|
123
|
+ |
String::from_utf8_lossy(&output.stderr)
|
|
124
|
+ |
);
|
|
125
|
+ |
}
|
|
126
|
+ |
|
|
127
|
+ |
let path = home
|
|
128
|
+ |
.path()
|
|
129
|
+ |
.join(".config")
|
|
130
|
+ |
.join("openagents")
|
|
131
|
+ |
.join("device-authorizations.json");
|
|
132
|
+ |
let stored: serde_json::Value =
|
|
133
|
+ |
serde_json::from_str(&std::fs::read_to_string(&path).expect("the file is on disk"))
|
|
134
|
+ |
.expect("concurrent writers left whole JSON behind");
|
|
135
|
+ |
assert_eq!(
|
|
136
|
+ |
stored["authorizations"][&origin]["user_code"], "AAAA-BBBB",
|
|
137
|
+ |
"the file survived but holds no authorization for the origin every writer used: {stored}"
|
|
138
|
+ |
);
|
|
139
|
+ |
assert_no_staging_files_left(path.parent().expect("the config directory"));
|
|
140
|
+ |
}
|
|
141
|
+ |
|
|
142
|
+ |
// ---------------------------------------------------------------------------
|
|
143
|
+ |
// the same overlap, one writer per thread, for each store in turn
|
|
144
|
+ |
// ---------------------------------------------------------------------------
|
|
145
|
+ |
|
|
146
|
+ |
/// Run `writer` on `WRITERS` threads at once and return what each one reported.
|
|
147
|
+ |
fn race<T, E>(writer: impl Fn(usize) -> Result<T, E> + Send + Sync + 'static) -> Vec<Result<T, E>>
|
|
148
|
+ |
where
|
|
149
|
+ |
T: Send + 'static,
|
|
150
|
+ |
E: Send + 'static,
|
|
151
|
+ |
{
|
|
152
|
+ |
let writer = std::sync::Arc::new(writer);
|
|
153
|
+ |
// A barrier rather than "spawn and hope": the point is that the writes
|
|
154
|
+ |
// overlap, and a thread that starts after another has finished proves
|
|
155
|
+ |
// nothing.
|
|
156
|
+ |
let gate = std::sync::Arc::new(std::sync::Barrier::new(WRITERS));
|
|
157
|
+ |
let threads: Vec<_> = (0..WRITERS)
|
|
158
|
+ |
.map(|index| {
|
|
159
|
+ |
let writer = writer.clone();
|
|
160
|
+ |
let gate = gate.clone();
|
|
161
|
+ |
thread::spawn(move || {
|
|
162
|
+ |
gate.wait();
|
|
163
|
+ |
writer(index)
|
|
164
|
+ |
})
|
|
165
|
+ |
})
|
|
166
|
+ |
.collect();
|
|
167
|
+ |
threads
|
|
168
|
+ |
.into_iter()
|
|
169
|
+ |
.map(|thread| thread.join().expect("a writer thread finished"))
|
|
170
|
+ |
.collect()
|
|
171
|
+ |
}
|
|
172
|
+ |
|
|
173
|
+ |
/// No `.tmp` litter: a staging file left behind is a write that half happened,
|
|
174
|
+ |
/// and in this directory it is a half-written credential sitting on disk.
|
|
175
|
+ |
fn assert_no_staging_files_left(directory: &Path) {
|
|
176
|
+ |
let left: Vec<String> = std::fs::read_dir(directory)
|
|
177
|
+ |
.expect("read the directory")
|
|
178
|
+ |
.flatten()
|
|
179
|
+ |
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
|
180
|
+ |
.filter(|name| name.ends_with(".tmp"))
|
|
181
|
+ |
.collect();
|
|
182
|
+ |
assert!(
|
|
183
|
+ |
left.is_empty(),
|
|
184
|
+ |
"staging files were left in {}: {left:?}",
|
|
185
|
+ |
directory.display()
|
|
186
|
+ |
);
|
|
187
|
+ |
}
|
|
188
|
+ |
|
|
189
|
+ |
#[cfg(unix)]
|
|
190
|
+ |
fn mode_of(path: &Path) -> u32 {
|
|
191
|
+ |
use std::os::unix::fs::PermissionsExt;
|
|
192
|
+ |
std::fs::metadata(path)
|
|
193
|
+ |
.expect("stat the path")
|
|
194
|
+ |
.permissions()
|
|
195
|
+ |
.mode()
|
|
196
|
+ |
& 0o777
|
|
197
|
+ |
}
|
|
198
|
+ |
|
|
199
|
+ |
/// Concurrent token writes all report where the token landed, and the store is
|
|
200
|
+ |
/// still readable afterwards.
|
|
201
|
+ |
///
|
|
202
|
+ |
/// Each writer uses its own origin, so the file is also the one place the
|
|
203
|
+ |
/// writes meet. The assertion is not that every origin survives — these are
|
|
204
|
+ |
/// read-modify-write callers and the last one legitimately wins — but that no
|
|
205
|
+ |
/// writer was told its token failed, and that the file the survivor left is a
|
|
206
|
+ |
/// credential store rather than two of them spliced together.
|
|
207
|
+ |
#[test]
|
|
208
|
+ |
fn concurrent_credential_writes_all_succeed_and_leave_a_readable_store() {
|
|
209
|
+ |
let directory = tempfile::tempdir().expect("a config directory");
|
|
210
|
+ |
let at = directory.path().to_path_buf();
|
|
211
|
+ |
|
|
212
|
+ |
let results = race(move |index| {
|
|
213
|
+ |
CredentialStore::isolated(&format!("https://writer-{index}.test"), &at)
|
|
214
|
+ |
.store(&Secret::new(format!("token-for-{index}")))
|
|
215
|
+ |
});
|
|
216
|
+ |
for (index, result) in results.iter().enumerate() {
|
|
217
|
+ |
assert!(
|
|
218
|
+ |
result.is_ok(),
|
|
219
|
+ |
"writer {index} was told its token was not stored: {}",
|
|
220
|
+ |
result.as_ref().unwrap_err()
|
|
221
|
+ |
);
|
|
222
|
+ |
}
|
|
223
|
+ |
|
|
224
|
+ |
let path = directory.path().join("credentials.json");
|
|
225
|
+ |
let stored: serde_json::Value =
|
|
226
|
+ |
serde_json::from_str(&std::fs::read_to_string(&path).expect("the store is on disk"))
|
|
227
|
+ |
.expect("concurrent writers left whole JSON behind");
|
|
228
|
+ |
let tokens = stored["tokens"]
|
|
229
|
+ |
.as_object()
|
|
230
|
+ |
.expect("the store holds a token map");
|
|
231
|
+ |
assert!(
|
|
232
|
+ |
!tokens.is_empty(),
|
|
233
|
+ |
"every writer reported success and the store holds nothing: {stored}"
|
|
234
|
+ |
);
|
|
235
|
+ |
for (origin, token) in tokens {
|
|
236
|
+ |
let index = origin
|
|
237
|
+ |
.trim_start_matches("https://writer-")
|
|
238
|
+ |
.trim_end_matches(".test");
|
|
239
|
+ |
assert_eq!(
|
|
240
|
+ |
token,
|
|
241
|
+ |
&serde_json::json!(format!("token-for-{index}")),
|
|
242
|
+ |
"the store pairs {origin} with a token no writer wrote, so two writes were spliced"
|
|
243
|
+ |
);
|
|
244
|
+ |
}
|
|
245
|
+ |
#[cfg(unix)]
|
|
246
|
+ |
{
|
|
247
|
+ |
assert_eq!(
|
|
248
|
+ |
mode_of(&path),
|
|
249
|
+ |
0o600,
|
|
250
|
+ |
"the store is readable to the machine"
|
|
251
|
+ |
);
|
|
252
|
+ |
assert_eq!(
|
|
253
|
+ |
mode_of(directory.path()),
|
|
254
|
+ |
0o700,
|
|
255
|
+ |
"the config directory is open to the machine"
|
|
256
|
+ |
);
|
|
257
|
+ |
}
|
|
258
|
+ |
assert_no_staging_files_left(directory.path());
|
|
259
|
+ |
}
|
|
260
|
+ |
|
|
261
|
+ |
/// The same for the half-finished logins, which is the file the reported
|
|
262
|
+ |
/// failure actually named.
|
|
263
|
+ |
#[test]
|
|
264
|
+ |
fn concurrent_pending_authorization_writes_all_succeed() {
|
|
265
|
+ |
let directory = tempfile::tempdir().expect("a config directory");
|
|
266
|
+ |
let path = directory.path().join("device-authorizations.json");
|
|
267
|
+ |
let at = path.clone();
|
|
268
|
+ |
|
|
269
|
+ |
let results = race(move |index| {
|
|
270
|
+ |
PendingStore::at(at.clone()).set(&PendingDeviceAuthorization {
|
|
271
|
+ |
origin: format!("https://writer-{index}.test"),
|
|
272
|
+ |
device_code: format!("device-{index}"),
|
|
273
|
+ |
user_code: format!("CODE-{index:04}"),
|
|
274
|
+ |
verification_uri: "https://example.test/device".to_string(),
|
|
275
|
+ |
verification_uri_complete: "https://example.test/device?user_code=X".to_string(),
|
|
276
|
+ |
expires_at_ms: 1_000_000,
|
|
277
|
+ |
interval: 5,
|
|
278
|
+ |
kind: None,
|
|
279
|
+ |
})
|
|
280
|
+ |
});
|
|
281
|
+ |
for (index, result) in results.iter().enumerate() {
|
|
282
|
+ |
assert!(
|
|
283
|
+ |
result.is_ok(),
|
|
284
|
+ |
"writer {index} was told its authorization was not stored: {}",
|
|
285
|
+ |
result.as_ref().unwrap_err()
|
|
286
|
+ |
);
|
|
287
|
+ |
}
|
|
288
|
+ |
|
|
289
|
+ |
let stored: serde_json::Value =
|
|
290
|
+ |
serde_json::from_str(&std::fs::read_to_string(&path).expect("the file is on disk"))
|
|
291
|
+ |
.expect("concurrent writers left whole JSON behind");
|
|
292
|
+ |
assert!(
|
|
293
|
+ |
!stored["authorizations"]
|
|
294
|
+ |
.as_object()
|
|
295
|
+ |
.expect("the file holds an authorization map")
|
|
296
|
+ |
.is_empty(),
|
|
297
|
+ |
"every writer reported success and the file holds nothing: {stored}"
|
|
298
|
+ |
);
|
|
299
|
+ |
#[cfg(unix)]
|
|
300
|
+ |
assert_eq!(mode_of(&path), 0o600, "the file is readable to the machine");
|
|
301
|
+ |
assert_no_staging_files_left(directory.path());
|
|
302
|
+ |
}
|
|
303
|
+ |
|
|
304
|
+ |
/// Concurrent seed writes all succeed and the seed that remains is one somebody
|
|
305
|
+ |
/// actually wrote.
|
|
306
|
+ |
///
|
|
307
|
+ |
/// The seed store staged through a fixed `seed.tmp` and removed it before each
|
|
308
|
+ |
/// write, so two writers could put half of one phrase and half of another in
|
|
309
|
+ |
/// the file that then got renamed over the seed. A seed is the one file where
|
|
310
|
+ |
/// that costs an identity outright: nothing recovers a mnemonic that is six
|
|
311
|
+ |
/// words from one wallet and six from another.
|
|
312
|
+ |
#[test]
|
|
313
|
+ |
fn concurrent_seed_writes_all_succeed_and_the_seed_still_opens() {
|
|
314
|
+ |
let directory = tempfile::tempdir().expect("an identity directory");
|
|
315
|
+ |
let at = directory.path().join("identity");
|
|
316
|
+ |
let phrases: Vec<String> = (0..WRITERS)
|
|
317
|
+ |
.map(|_| generate_seed_phrase(12).expect("a phrase"))
|
|
318
|
+ |
.collect();
|
|
319
|
+ |
|
|
320
|
+ |
let written = phrases.clone();
|
|
321
|
+ |
let target = at.clone();
|
|
322
|
+ |
let results = race(move |index| {
|
|
323
|
+ |
SeedStore::with_key_store(target.clone(), Box::new(NoKeyStore))
|
|
324
|
+ |
.write_phrase(&written[index])
|
|
325
|
+ |
});
|
|
326
|
+ |
for (index, result) in results.iter().enumerate() {
|
|
327
|
+ |
assert!(
|
|
328
|
+ |
result.is_ok(),
|
|
329
|
+ |
"writer {index} was told the seed was not written: {}",
|
|
330
|
+ |
result.as_ref().unwrap_err()
|
|
331
|
+ |
);
|
|
332
|
+ |
}
|
|
333
|
+ |
|
|
334
|
+ |
let store = SeedStore::with_key_store(at.clone(), Box::new(NoKeyStore));
|
|
335
|
+ |
let recovered = store
|
|
336
|
+ |
.read_phrase()
|
|
337
|
+ |
.expect("the seed opens")
|
|
338
|
+ |
.expect("a seed is stored");
|
|
339
|
+ |
assert!(
|
|
340
|
+ |
phrases.contains(&recovered),
|
|
341
|
+ |
"the stored seed is not any phrase that was written, so two writes were spliced"
|
|
342
|
+ |
);
|
|
343
|
+ |
#[cfg(unix)]
|
|
344
|
+ |
{
|
|
345
|
+ |
assert_eq!(
|
|
346
|
+ |
mode_of(&store.path()),
|
|
347
|
+ |
0o600,
|
|
348
|
+ |
"the seed is readable to the machine"
|
|
349
|
+ |
);
|
|
350
|
+ |
assert_eq!(
|
|
351
|
+ |
mode_of(&at),
|
|
352
|
+ |
0o700,
|
|
353
|
+ |
"the identity directory is open to the machine"
|
|
354
|
+ |
);
|
|
355
|
+ |
}
|
|
356
|
+ |
assert_no_staging_files_left(&at);
|
|
357
|
+ |
}
|
|
358
|
+ |
|
|
359
|
+ |
/// A reader of `computer.json` never sees a policy it cannot decode, however
|
|
360
|
+ |
/// many writers are working on it.
|
|
361
|
+ |
///
|
|
362
|
+ |
/// `computer.json` wrote in place — truncate the target, then write it — so
|
|
363
|
+ |
/// between those two calls the file on disk is empty, and any `oa` that read
|
|
364
|
+ |
/// the policy in that window was told its own configuration is not valid JSON.
|
|
365
|
+ |
/// It is the policy file: the answer decides which commands the Computer will
|
|
366
|
+ |
/// run at all, and `load_config` is right to refuse a file it cannot read
|
|
367
|
+ |
/// rather than fall back to a default the owner never chose. So the refusal
|
|
368
|
+ |
/// lands on a reader that did nothing wrong.
|
|
369
|
+ |
///
|
|
370
|
+ |
/// Writers alone would not settle this. Two small `write` calls usually land
|
|
371
|
+ |
/// whole, so a test that only writes passes against the broken version. Readers
|
|
372
|
+ |
/// are what make the truncate window visible, and staging elsewhere and
|
|
373
|
+ |
/// renaming is what closes it: the target is only ever the old file or the new
|
|
374
|
+ |
/// one.
|
|
375
|
+ |
#[test]
|
|
376
|
+ |
fn a_reader_never_sees_a_half_written_computer_policy() {
|
|
377
|
+ |
/// Enough passes for the truncate window to be observed if it is open.
|
|
378
|
+ |
const PASSES: usize = 40;
|
|
379
|
+ |
|
|
380
|
+ |
let directory = tempfile::tempdir().expect("a config directory");
|
|
381
|
+ |
let paths = ComputerPaths::in_directory(directory.path());
|
|
382
|
+ |
// Seed the file, so a reader finding it absent means it was unlinked rather
|
|
383
|
+ |
// than never written.
|
|
384
|
+ |
let mut initial = PolicyConfig::closed(paths.clone());
|
|
385
|
+ |
initial.pre_approved = vec!["writer-initial".to_string()];
|
|
386
|
+ |
openagents_cli::computer::write_config(&initial).expect("the first write lands");
|
|
387
|
+ |
|
|
388
|
+ |
let at = paths.clone();
|
|
389
|
+ |
let results = race(move |index| {
|
|
390
|
+ |
// Half write and half read, so both are going at once.
|
|
391
|
+ |
if index.is_multiple_of(2) {
|
|
392
|
+ |
for pass in 0..PASSES {
|
|
393
|
+ |
let mut config = PolicyConfig::closed(at.clone());
|
|
394
|
+ |
// Lengths differ between writers, so an interleave leaves a tail
|
|
395
|
+ |
// rather than a file that happens to be the same size.
|
|
396
|
+ |
config.pre_approved = (0..=index * pass % 7)
|
|
397
|
+ |
.map(|n| format!("writer-{index}-pass-{pass}-entry-{n}"))
|
|
398
|
+ |
.collect();
|
|
399
|
+ |
openagents_cli::computer::write_config(&config)
|
|
400
|
+ |
.map_err(|error| format!("writer {index} pass {pass}: {error}"))?;
|
|
401
|
+ |
}
|
|
402
|
+ |
} else {
|
|
403
|
+ |
for pass in 0..PASSES {
|
|
404
|
+ |
openagents_cli::computer::load_config(&at)
|
|
405
|
+ |
.map(|_| ())
|
|
406
|
+ |
.map_err(|error| format!("reader {index} pass {pass}: {error}"))?;
|
|
407
|
+ |
}
|
|
408
|
+ |
}
|
|
409
|
+ |
Ok::<(), String>(())
|
|
410
|
+ |
});
|
|
411
|
+ |
for result in &results {
|
|
412
|
+ |
assert!(
|
|
413
|
+ |
result.is_ok(),
|
|
414
|
+ |
"a concurrent run of the policy file failed: {}",
|
|
415
|
+ |
result.as_ref().unwrap_err()
|
|
416
|
+ |
);
|
|
417
|
+ |
}
|
|
418
|
+ |
|
|
419
|
+ |
let settled = openagents_cli::computer::load_config(&paths)
|
|
420
|
+ |
.expect("the policy the writers left behind still decodes");
|
|
421
|
+ |
assert!(
|
|
422
|
+ |
settled
|
|
423
|
+ |
.pre_approved
|
|
424
|
+ |
.iter()
|
|
425
|
+ |
.all(|entry| entry.starts_with("writer-")),
|
|
426
|
+ |
"the policy holds an entry no writer wrote, so two writes were spliced: {:?}",
|
|
427
|
+ |
settled.pre_approved
|
|
428
|
+ |
);
|
|
429
|
+ |
#[cfg(unix)]
|
|
430
|
+ |
assert_eq!(
|
|
431
|
+ |
mode_of(&paths.config),
|
|
432
|
+ |
0o600,
|
|
433
|
+ |
"the configuration is readable to the machine"
|
|
434
|
+ |
);
|
|
435
|
+ |
assert_no_staging_files_left(directory.path());
|
|
436
|
+ |
}
|