1use crate::codec::profile_document_codec::SingBoxProfileDocumentCodec;
4use crate::process::command::{NativeConfigCheck, SingBoxCommandPort, TokioSingBoxCommandPort};
5use lurker_core_contract::error::{ErrorCode, Failure};
6use lurker_core_contract::profile_document::{DocumentDiagnosticSnapshot, DocumentValidation};
7use lurker_core_ports::error::PortError;
8use lurker_core_ports::profile_document_codec::ProfileDocumentCodecPort;
9#[cfg(test)]
10use std::ffi::OsStr;
11use std::io::{self, ErrorKind};
12use std::path::{Path, PathBuf};
13use std::process;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16use tokio::fs::{self, OpenOptions};
17use tokio::io::AsyncWriteExt;
18use tokio::sync::Mutex;
19
20static TEMP_CONFIG_SEQUENCE: AtomicU64 = AtomicU64::new(0);
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SingBoxConfigApplyReceipt {
25 pub persisted_byte_count: usize,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum SingBoxConfigFileState {
32 Missing,
34 Present(Vec<u8>),
36}
37
38pub struct SingBoxProfileConfigurationAdapter {
47 binary_path: PathBuf,
48 config_path: PathBuf,
49 command: Arc<dyn SingBoxCommandPort>,
50 codec: SingBoxProfileDocumentCodec,
51 apply_lock: Mutex<()>,
52}
53
54impl SingBoxProfileConfigurationAdapter {
55 pub fn with_tokio(binary_path: PathBuf, config_path: PathBuf) -> Self {
57 Self::new(binary_path, config_path, Arc::new(TokioSingBoxCommandPort))
58 }
59
60 pub fn new(
62 binary_path: PathBuf,
63 config_path: PathBuf,
64 command: Arc<dyn SingBoxCommandPort>,
65 ) -> Self {
66 Self {
67 binary_path,
68 config_path,
69 command,
70 codec: SingBoxProfileDocumentCodec,
71 apply_lock: Mutex::new(()),
72 }
73 }
74
75 pub async fn validate_native(&self, raw_bytes: &[u8]) -> Result<DocumentValidation, PortError> {
80 match self.codec.inspect(raw_bytes)? {
81 DocumentValidation::Invalid { diagnostics } => {
82 Ok(DocumentValidation::Invalid { diagnostics })
83 }
84 DocumentValidation::Valid => {
85 let staged = create_temporary_config(&self.config_path, raw_bytes).await?;
86 let check = self
87 .command
88 .check_config(&self.binary_path, staged.path())
89 .await;
90 drop(staged);
91 match check? {
92 NativeConfigCheck::Accepted => Ok(DocumentValidation::Valid),
93 NativeConfigCheck::Rejected => Ok(DocumentValidation::Invalid {
94 diagnostics: vec![DocumentDiagnosticSnapshot {
95 line: None,
96 column: None,
97 message: "sing-box native config check rejected this document"
98 .to_owned(),
99 }],
100 }),
101 }
102 }
103 }
104 }
105
106 pub async fn apply_and_readback(
111 &self,
112 raw_bytes: &[u8],
113 ) -> Result<SingBoxConfigApplyReceipt, PortError> {
114 let _transaction = self.apply_lock.lock().await;
115 let validated = SubmittedConfig(raw_bytes.to_vec())
116 .after_native_validation(self)
117 .await?;
118 let captured = validated.capture_previous(&self.config_path).await?;
119 let installed = captured.install(&self.config_path).await?;
120 match installed.verify_readback(&self.config_path).await {
121 Ok(receipt) => Ok(receipt),
122 Err(failure) => {
123 let rollback = restore_previous_config(&self.config_path, failure.previous).await;
124 Err(readback_failure(failure.cause, rollback))
125 }
126 }
127 }
128
129 pub async fn readback(&self) -> Result<Vec<u8>, PortError> {
131 fs::read(&self.config_path)
132 .await
133 .map_err(|error| map_storage_error(&self.config_path, error))
134 }
135
136 pub async fn file_state(&self) -> Result<SingBoxConfigFileState, PortError> {
138 let _transaction = self.apply_lock.lock().await;
139 read_previous_config(&self.config_path).await
140 }
141
142 pub async fn restore_file_state(
144 &self,
145 expected: &SingBoxConfigFileState,
146 ) -> Result<(), PortError> {
147 let _transaction = self.apply_lock.lock().await;
148 restore_previous_config(&self.config_path, expected.clone()).await?;
149 let observed = read_previous_config(&self.config_path).await?;
150 match classify_exact_readback(observed, expected) {
151 ExactReadback::Exact(_) => Ok(()),
152 ExactReadback::Different => Err(PortError::Failed(
153 "sing-box native config rollback readback differed from the previous file state"
154 .to_owned(),
155 )),
156 }
157 }
158}
159
160struct SubmittedConfig(Vec<u8>);
161
162struct ValidatedConfig(Vec<u8>);
163
164struct PreviousConfigCaptured {
165 raw_bytes: Vec<u8>,
166 previous: SingBoxConfigFileState,
167}
168
169struct InstalledConfig {
170 raw_bytes: Vec<u8>,
171 previous: SingBoxConfigFileState,
172}
173
174struct FailedConfigReadback {
175 previous: SingBoxConfigFileState,
176 cause: ConfigReadbackFailure,
177}
178
179enum ConfigReadbackFailure {
180 BytesDiffer,
181 Unavailable(PortError),
182}
183
184enum ExactReadback<T> {
185 Exact(T),
186 Different,
187}
188
189impl SubmittedConfig {
190 async fn after_native_validation(
191 self,
192 adapter: &SingBoxProfileConfigurationAdapter,
193 ) -> Result<ValidatedConfig, PortError> {
194 match adapter.validate_native(&self.0).await? {
195 DocumentValidation::Valid => Ok(ValidatedConfig(self.0)),
196 DocumentValidation::Invalid { .. } => Err(PortError::Rejected(Failure::new(
197 ErrorCode::Configuration,
198 "sing-box rejected the native JSON configuration",
199 false,
200 ))),
201 }
202 }
203}
204
205impl ValidatedConfig {
206 async fn capture_previous(
207 self,
208 config_path: &Path,
209 ) -> Result<PreviousConfigCaptured, PortError> {
210 Ok(PreviousConfigCaptured {
211 raw_bytes: self.0,
212 previous: read_previous_config(config_path).await?,
213 })
214 }
215}
216
217impl PreviousConfigCaptured {
218 async fn install(self, config_path: &Path) -> Result<InstalledConfig, PortError> {
219 atomic_write(config_path, &self.raw_bytes).await?;
220 Ok(InstalledConfig {
221 raw_bytes: self.raw_bytes,
222 previous: self.previous,
223 })
224 }
225}
226
227impl InstalledConfig {
228 async fn verify_readback(
229 self,
230 config_path: &Path,
231 ) -> Result<SingBoxConfigApplyReceipt, FailedConfigReadback> {
232 match fs::read(config_path).await {
233 Ok(readback) => match classify_exact_readback(readback, &self.raw_bytes) {
234 ExactReadback::Exact(readback) => Ok(SingBoxConfigApplyReceipt {
235 persisted_byte_count: readback.len(),
236 }),
237 ExactReadback::Different => Err(FailedConfigReadback {
238 previous: self.previous,
239 cause: ConfigReadbackFailure::BytesDiffer,
240 }),
241 },
242 Err(error) => Err(FailedConfigReadback {
243 previous: self.previous,
244 cause: ConfigReadbackFailure::Unavailable(map_storage_error(config_path, error)),
245 }),
246 }
247 }
248}
249
250fn classify_exact_readback<T: PartialEq>(observed: T, expected: &T) -> ExactReadback<T> {
251 if &observed == expected {
252 ExactReadback::Exact(observed)
253 } else {
254 ExactReadback::Different
255 }
256}
257
258struct TemporaryConfigFile {
259 path: PathBuf,
260}
261
262impl TemporaryConfigFile {
263 fn path(&self) -> &Path {
264 &self.path
265 }
266}
267
268impl Drop for TemporaryConfigFile {
269 fn drop(&mut self) {
270 use std::fs;
271 let _ = fs::remove_file(&self.path);
272 }
273}
274
275async fn create_temporary_config(
276 active_path: &Path,
277 raw_bytes: &[u8],
278) -> Result<TemporaryConfigFile, PortError> {
279 let parent = active_path
280 .parent()
281 .ok_or_else(|| PortError::Io("sing-box config path has no parent directory".to_owned()))?;
282 ensure_directory(parent).await?;
283 let file_name = active_path
284 .file_name()
285 .map(|name| name.to_string_lossy())
286 .unwrap_or_else(|| "config.json".into());
287
288 for _ in 0..32 {
289 let sequence = TEMP_CONFIG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
290 let candidate = parent.join(format!(
291 ".{file_name}.candidate-{}-{sequence}.json",
292 process::id()
293 ));
294 let open = OpenOptions::new()
295 .write(true)
296 .create_new(true)
297 .open(&candidate)
298 .await;
299 match open {
300 Ok(mut file) => {
301 let write_result = async {
302 file.write_all(raw_bytes).await?;
303 file.sync_all().await
304 }
305 .await;
306 if let Err(error) = write_result {
307 let _ = fs::remove_file(&candidate).await;
308 return Err(map_storage_error(&candidate, error));
309 }
310 return Ok(TemporaryConfigFile { path: candidate });
311 }
312 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
313 Err(error) => return Err(map_storage_error(&candidate, error)),
314 }
315 }
316
317 Err(PortError::Io(
318 "could not reserve a temporary sing-box config file".to_owned(),
319 ))
320}
321
322async fn atomic_write(active_path: &Path, raw_bytes: &[u8]) -> Result<(), PortError> {
323 let staged = create_temporary_config(active_path, raw_bytes).await?;
324 fs::rename(staged.path(), active_path)
325 .await
326 .map_err(|error| map_storage_error(active_path, error))?;
327 Ok(())
328}
329
330async fn read_previous_config(path: &Path) -> Result<SingBoxConfigFileState, PortError> {
331 match fs::read(path).await {
332 Ok(bytes) => Ok(SingBoxConfigFileState::Present(bytes)),
333 Err(error) if error.kind() == ErrorKind::NotFound => Ok(SingBoxConfigFileState::Missing),
334 Err(error) => Err(map_storage_error(path, error)),
335 }
336}
337
338async fn restore_previous_config(
339 active_path: &Path,
340 previous: SingBoxConfigFileState,
341) -> Result<(), PortError> {
342 match previous {
343 SingBoxConfigFileState::Missing => match fs::remove_file(active_path).await {
344 Ok(()) => Ok(()),
345 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
346 Err(error) => Err(map_storage_error(active_path, error)),
347 },
348 SingBoxConfigFileState::Present(bytes) => atomic_write(active_path, &bytes).await,
349 }
350}
351
352async fn ensure_directory(path: &Path) -> Result<(), PortError> {
353 match fs::metadata(path).await {
354 Ok(metadata) if metadata.is_dir() => Ok(()),
355 Ok(_) => Err(PortError::Io(
356 "sing-box config parent is not a directory".to_owned(),
357 )),
358 Err(error) => Err(map_storage_error(path, error)),
359 }
360}
361
362fn readback_failure(cause: ConfigReadbackFailure, rollback: Result<(), PortError>) -> PortError {
363 match (cause, rollback) {
364 (ConfigReadbackFailure::BytesDiffer, Ok(())) => PortError::Io(
365 "sing-box config readback differed from the submitted bytes; previous file restored"
366 .to_owned(),
367 ),
368 (ConfigReadbackFailure::Unavailable(cause), Ok(())) => PortError::Io(format!(
369 "sing-box config readback failed; previous file restored: {cause}"
370 )),
371 (cause, Err(rollback)) => PortError::Failed(format!(
372 "sing-box config readback failed and previous file restoration failed: {} / {rollback}",
373 readback_cause(&cause)
374 )),
375 }
376}
377
378fn readback_cause(cause: &ConfigReadbackFailure) -> &'static str {
379 match cause {
380 ConfigReadbackFailure::BytesDiffer => "readback bytes differed",
381 ConfigReadbackFailure::Unavailable(_) => "readback was unavailable",
382 }
383}
384
385fn map_storage_error(path: &Path, error: io::Error) -> PortError {
386 match error.kind() {
387 ErrorKind::NotFound => PortError::NotFound(format!(
388 "sing-box config file is unavailable: {}",
389 path.display()
390 )),
391 ErrorKind::PermissionDenied => {
392 PortError::PermissionDenied("sing-box config file cannot be accessed".to_owned())
393 }
394 _ => PortError::Io(format!("sing-box config file operation failed: {error}")),
395 }
396}
397
398#[cfg(test)]
399mod transaction_recovery_tests {
400 use super::*;
401 use tempfile::TempDir;
402
403 #[derive(Clone, Copy)]
404 enum ReadbackFault {
405 DifferentBytes,
406 MissingFile,
407 }
408
409 #[derive(Clone, Copy)]
410 enum PreviousFile {
411 Missing,
412 Present(&'static [u8]),
413 }
414
415 impl PreviousFile {
416 fn into_state(self) -> SingBoxConfigFileState {
417 match self {
418 Self::Missing => SingBoxConfigFileState::Missing,
419 Self::Present(bytes) => SingBoxConfigFileState::Present(bytes.to_vec()),
420 }
421 }
422
423 fn assert_restored(self, path: &Path) {
424 match self {
425 Self::Missing => assert!(
426 !path.exists(),
427 "failed transaction must remove a newly-created config"
428 ),
429 Self::Present(expected) => {
430 use std::fs;
431 assert_eq!(fs::read(path).unwrap(), expected);
432 }
433 }
434 }
435 }
436
437 #[tokio::test]
438 async fn failed_config_readback_restores_previous_bytes_or_removes_new_file() {
439 for fault in [ReadbackFault::DifferentBytes, ReadbackFault::MissingFile] {
440 for previous in [
441 PreviousFile::Missing,
442 PreviousFile::Present(b"previous-config"),
443 ] {
444 let home = TempDir::new().unwrap();
445 let config_path = home.path().join("config.json");
446 let proposed = b"submitted-config";
447 fs::write(&config_path, proposed).await.unwrap();
448 match fault {
449 ReadbackFault::DifferentBytes => {
450 fs::write(&config_path, b"externally-changed")
451 .await
452 .unwrap();
453 }
454 ReadbackFault::MissingFile => {
455 fs::remove_file(&config_path).await.unwrap();
456 }
457 }
458
459 let installed = InstalledConfig {
460 raw_bytes: proposed.to_vec(),
461 previous: previous.into_state(),
462 };
463 let failure = installed
464 .verify_readback(&config_path)
465 .await
466 .expect_err("the injected readback fault must fail verification");
467 let reported = readback_failure(
468 failure.cause,
469 restore_previous_config(&config_path, failure.previous).await,
470 );
471
472 assert!(matches!(reported, PortError::Io(_)));
473 previous.assert_restored(&config_path);
474 let mut entries = fs::read_dir(home.path()).await.unwrap();
475 let first = entries.next_entry().await.unwrap();
476 match previous {
477 PreviousFile::Missing => assert!(first.is_none()),
478 PreviousFile::Present(_) => {
479 assert_eq!(first.unwrap().file_name(), OsStr::new("config.json"));
480 assert!(entries.next_entry().await.unwrap().is_none());
481 }
482 }
483 }
484 }
485 }
486}