Skip to main content

singbox_driver/codec/
config.rs

1//! Native sing-box JSON validation and exact config-file readback.
2
3use 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/// The receipt of one committed sing-box configuration file.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct SingBoxConfigApplyReceipt {
25    /// The number of bytes the read-back file holds.
26    pub persisted_byte_count: usize,
27}
28
29/// Exact pre-transaction state of the active native JSON file.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum SingBoxConfigFileState {
32    /// The configuration file does not exist.
33    Missing,
34    /// The configuration file exists with the exact bytes.
35    Present(Vec<u8>),
36}
37
38/// Owns sing-box native validation and its active JSON file.
39///
40/// `apply_and_readback` validates before replacing the file, installs the
41/// exact submitted bytes atomically, and verifies those bytes from disk. The
42/// caller still owns process restart/readiness and must not treat this file
43/// receipt as proof that a running core loaded the document. Transactions are
44/// serialized per adapter instance. A config path must have exactly one
45/// adapter owner; separate instances or processes sharing it are not locked.
46pub 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    /// Creates the adapter that launches the binary through tokio.
56    pub fn with_tokio(binary_path: PathBuf, config_path: PathBuf) -> Self {
57        Self::new(binary_path, config_path, Arc::new(TokioSingBoxCommandPort))
58    }
59
60    /// Creates the adapter with an injected command boundary.
61    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    /// Validates the document with the native sing-box `check` transaction.
76    ///
77    /// A document that the JSON codec rejects is never staged; a document the
78    /// codec accepts is staged in a temporary file and checked by the engine.
79    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    /// Validates, installs and reads back the exact submitted bytes.
107    ///
108    /// A read-back that does not match rolls the previous file back and
109    /// reports the typed failure.
110    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    /// Reads the exact bytes of the active configuration file.
130    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    /// Reads the exact state of the active configuration file.
137    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    /// Restores a transaction snapshot and verifies the resulting file state.
143    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}