Skip to main content

subc_transport/
connection_file.rs

1use std::{
2    env,
3    error::Error,
4    ffi::{OsStr, OsString},
5    fmt,
6    fs::{self, File, OpenOptions},
7    io::{self, Read, Write},
8    path::{Path, PathBuf},
9    process,
10    time::{Duration, SystemTime},
11};
12
13use serde::{Deserialize, Serialize};
14use subc_protocol::PROTOCOL_VERSION;
15
16pub const SCHEMA_VERSION: u32 = 1;
17pub const MIN_KEY_LEN: usize = 32;
18pub const KEY_LEN: usize = 32;
19pub const DAEMON_ID_LEN: usize = 16;
20
21/// The daemon's connection-file name. Public so the writer (bootstrap) and
22/// every reader spell it once; three private copies of this literal used to
23/// exist and nothing asserted they agreed.
24pub const CONNECTION_FILE_NAME: &str = "subc-connection.json";
25const PROD_CONNECTION_RELATIVE_PATH: &[&str] =
26    &[".local", "share", "cortexkit", "run", CONNECTION_FILE_NAME];
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Endpoint {
30    pub host: String,
31    pub port: u16,
32}
33
34#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ConnectionInfo {
36    pub schema: u32,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub wire_version: Option<u8>,
39    pub endpoints: Vec<Endpoint>,
40    pub key: Vec<u8>,
41    pub daemon_id: [u8; DAEMON_ID_LEN],
42    pub pid: u32,
43    pub daemon_ver: String,
44}
45
46// Hand-written so the transport key is never printed. A derived Debug would dump
47// the raw key bytes into any log or panic message that formats a ConnectionInfo.
48impl fmt::Debug for ConnectionInfo {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.debug_struct("ConnectionInfo")
51            .field("schema", &self.schema)
52            .field("wire_version", &self.wire_version)
53            .field("endpoints", &self.endpoints)
54            .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
55            .field("daemon_id", &self.daemon_id)
56            .field("pid", &self.pid)
57            .field("daemon_ver", &self.daemon_ver)
58            .finish()
59    }
60}
61
62/// A connection file selected by reader-side discovery.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Discovered {
65    pub path: PathBuf,
66    pub info: ConnectionInfo,
67}
68
69/// One connection-file candidate that could not be read or parsed.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct TriedCandidate {
72    pub path: PathBuf,
73    pub reason: String,
74}
75
76/// Every connection-file candidate reader-side discovery tried.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DiscoveryError {
79    pub tried: Vec<TriedCandidate>,
80}
81
82impl fmt::Display for DiscoveryError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        let rendered = self
85            .tried
86            .iter()
87            .map(|attempt| format!("{} ({})", attempt.path.display(), attempt.reason))
88            .collect::<Vec<_>>()
89            .join(", ");
90        write!(f, "no usable subc connection file found; tried: {rendered}")
91    }
92}
93
94impl Error for DiscoveryError {}
95
96impl ConnectionInfo {
97    pub fn validate(&self) -> Result<(), ConnectionFileError> {
98        if self.schema != SCHEMA_VERSION {
99            return Err(ConnectionFileError::UnsupportedSchema {
100                schema: self.schema,
101                supported: SCHEMA_VERSION,
102            });
103        }
104        if self.endpoints.is_empty() {
105            return Err(ConnectionFileError::Invalid {
106                reason: "connection file must include at least one endpoint".to_owned(),
107            });
108        }
109        if self.key.len() < MIN_KEY_LEN {
110            return Err(ConnectionFileError::KeyTooShort {
111                len: self.key.len(),
112                min: MIN_KEY_LEN,
113            });
114        }
115        Ok(())
116    }
117
118    /// Validates a declared envelope version without rejecting older files that
119    /// omit the additive field.
120    pub fn validate_wire_version(&self, supported: u8) -> Result<(), ConnectionFileError> {
121        if let Some(file) = self.wire_version {
122            if file != supported {
123                return Err(ConnectionFileError::WireVersionMismatch { file, supported });
124            }
125        }
126        Ok(())
127    }
128}
129
130#[derive(Debug)]
131pub enum ConnectionFileError {
132    MissingParent {
133        path: PathBuf,
134    },
135    MissingFileName {
136        path: PathBuf,
137    },
138    Io {
139        op: &'static str,
140        path: PathBuf,
141        source: io::Error,
142    },
143    JsonRead {
144        path: PathBuf,
145        source: serde_json::Error,
146    },
147    JsonWrite {
148        path: PathBuf,
149        source: serde_json::Error,
150    },
151    Random(getrandom::Error),
152    UnsupportedSchema {
153        schema: u32,
154        supported: u32,
155    },
156    WireVersionMismatch {
157        file: u8,
158        supported: u8,
159    },
160    Invalid {
161        reason: String,
162    },
163    KeyTooShort {
164        len: usize,
165        min: usize,
166    },
167    InsecurePermissions {
168        path: PathBuf,
169        mode: u32,
170    },
171    InsecureParentDirectory {
172        component: PathBuf,
173        mode: u32,
174    },
175}
176
177pub fn write_atomic(
178    path: impl AsRef<Path>,
179    info: &ConnectionInfo,
180) -> Result<(), ConnectionFileError> {
181    let path = path.as_ref();
182    info.validate()?;
183
184    let parent = path
185        .parent()
186        .filter(|parent| !parent.as_os_str().is_empty())
187        .ok_or_else(|| ConnectionFileError::MissingParent {
188            path: path.to_path_buf(),
189        })?;
190    let file_name = path
191        .file_name()
192        .ok_or_else(|| ConnectionFileError::MissingFileName {
193            path: path.to_path_buf(),
194        })?;
195    ensure_parent_directory(parent)?;
196    refuse_writable_ancestor(parent)?;
197    // Sweep temps stranded by an earlier writer before creating our own. The
198    // error path below removes this call's temp, but nothing removes one left by
199    // a process that died BETWEEN create and rename -- and a connection file
200    // carries the daemon's auth key, so a stranded temp is a stale credential
201    // sitting in the runtime directory indefinitely. Owner-only mode means no
202    // other user can read it and the key dies with the daemon that minted it;
203    // the objection is to key material with no owner and no expiry, not to an
204    // active leak.
205    //
206    // Best-effort and non-fatal: publishing must not fail because cleanup could
207    // not remove somebody else's file.
208    sweep_stale_temps(parent, file_name);
209
210    let temp_path = temp_path(parent, file_name)?;
211    let result = write_atomic_inner(path, &temp_path, info);
212    if result.is_err() {
213        let _ = fs::remove_file(&temp_path);
214    }
215    result
216}
217
218/// Create the connection file's parent with owner-only permissions when it is
219/// absent.
220///
221/// The daemon OWNS this directory, so it can make the misconfiguration
222/// unproducible rather than only reporting it — which is strictly stronger than
223/// the check below and is available to us precisely because we choose the path.
224/// A caller that names its own destination has only the check.
225///
226/// An existing directory is left alone: changing modes under an operator is a
227/// bigger act than refusing, and `refuse_writable_ancestor` reports it.
228fn ensure_parent_directory(parent: &Path) -> Result<(), ConnectionFileError> {
229    if parent.exists() {
230        return Ok(());
231    }
232    #[cfg(unix)]
233    let created = {
234        use std::os::unix::fs::DirBuilderExt;
235        fs::DirBuilder::new()
236            .recursive(true)
237            .mode(0o700)
238            .create(parent)
239    };
240    #[cfg(not(unix))]
241    let created = fs::create_dir_all(parent);
242
243    created.map_err(|source| ConnectionFileError::Io {
244        op: "create connection-file parent",
245        path: parent.to_path_buf(),
246        source,
247    })
248}
249
250/// Refuse to publish key material beneath a directory another user can write.
251///
252/// This catches misconfiguration; it is not a security boundary. A process
253/// running as the same user can read the finished 0600 file anyway, so the
254/// check does nothing against one. What it catches is another user: whoever can
255/// write a directory can unlink and create entries in it, whatever the mode or
256/// owner of the file inside, so another user with write access to any ancestor
257/// could delete the 0600 file and put their own in its place.
258///
259/// The check covers every ancestor up to `/`. Stopping at `$HOME` or an XDG
260/// base would take the bound from the environment, which an attacker can
261/// influence and which may be unset. It would also be wrong: an attacker who can
262/// unlink in any ancestor can rename an intermediate directory aside and put
263/// their own tree in its place, so a 0700 leaf under a 0777 grandparent protects
264/// nothing.
265///
266/// The path is canonicalised first, resolving symlinks. Walking the unresolved
267/// path would check the modes of a path other than the one written through: a
268/// symlink component pointing somewhere permissive would pass every `stat`.
269///
270/// Each ancestor must be owned by the effective user or by root, and is refused
271/// otherwise: even a 0755 directory can be renamed or have its permissions
272/// changed by its owner. Root is trusted because the transport does not defend
273/// against root processes.
274///
275/// A group- or world-writable ancestor is refused unless it is sticky. The
276/// sticky bit lets only an entry's owner remove or rename it, and `/tmp` and
277/// `/Users/Shared` are 1777 by design; without this exemption the check would
278/// fire on correctly configured systems, and a check that refuses healthy
279/// configuration gets disabled, after which it protects nothing. A sticky
280/// directory must still pass the ownership check above.
281#[cfg(unix)]
282fn refuse_writable_ancestor(parent: &Path) -> Result<(), ConnectionFileError> {
283    refuse_writable_ancestor_for_uid(parent, rustix::process::geteuid().as_raw())
284}
285
286#[cfg(unix)]
287fn refuse_writable_ancestor_for_uid(parent: &Path, uid: u32) -> Result<(), ConnectionFileError> {
288    use std::os::unix::fs::{MetadataExt, PermissionsExt};
289
290    const GROUP_OR_WORLD_WRITABLE: u32 = 0o022;
291    const STICKY: u32 = 0o1000;
292
293    // A parent that cannot be canonicalised is reported by the write itself with
294    // its own io::Error; refusing here would replace a precise errno with a
295    // permissions verdict about a path we could not resolve.
296    let Ok(resolved) = fs::canonicalize(parent) else {
297        return Ok(());
298    };
299
300    let mut component = resolved.as_path();
301    loop {
302        // A component whose metadata cannot be read is SKIPPED, not refused, and
303        // the silence is deliberate. Canonicalising above already required
304        // traverse permission on every component, so a failure here is close to
305        // unreachable and is a transient io error rather than evidence about
306        // permissions; refusing on it would convert that error into a confident
307        // verdict about a mode we never observed, which is the same trade the
308        // canonicalise arm declines. Argued at the site because an UNARGUED
309        // fail-open is the one a later reader tightens into a refusal.
310        if let Ok(metadata) = fs::metadata(component) {
311            if metadata.uid() != uid && metadata.uid() != 0 {
312                return Err(ConnectionFileError::Invalid {
313                    reason: format!(
314                        "connection-file ancestor {} is owned by uid {}, expected effective uid {uid} or root",
315                        component.display(), metadata.uid()
316                    ),
317                });
318            }
319            let mode = metadata.permissions().mode();
320            if mode & GROUP_OR_WORLD_WRITABLE != 0 && mode & STICKY == 0 {
321                return Err(ConnectionFileError::InsecureParentDirectory {
322                    component: component.to_path_buf(),
323                    mode: mode & 0o7777,
324                });
325            }
326        }
327        match component.parent() {
328            Some(next) => component = next,
329            None => return Ok(()),
330        }
331    }
332}
333
334#[cfg(not(unix))]
335fn refuse_writable_ancestor(_parent: &Path) -> Result<(), ConnectionFileError> {
336    // Windows ACLs are not a mode bitmask and the Unix reasoning does not carry.
337    // Stated rather than silently skipped so the absence is a decision.
338    Ok(())
339}
340
341/// Remove `.<file_name>.<pid>.<hex>.tmp` siblings older than ten minutes.
342///
343/// AGE IS THE SOLE PREDICATE. Testing whether the embedded pid is alive reads
344/// false-positive on exactly the oldest files, because pid numbers are recycled:
345/// an unrelated long-lived process inherits the number and the stalest temp
346/// looks owned. That failure direction resembles caution, which is why nobody
347/// investigates the survivors. Ten minutes is far longer than the window this
348/// guards, which spans two syscalls.
349fn sweep_stale_temps(parent: &Path, file_name: &std::ffi::OsStr) {
350    const STALE_AFTER: Duration = Duration::from_secs(600);
351
352    let prefix = format!(".{}.", file_name.to_string_lossy());
353    let Ok(entries) = fs::read_dir(parent) else {
354        return;
355    };
356    for entry in entries.flatten() {
357        let name = entry.file_name();
358        let name = name.to_string_lossy();
359        if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
360            continue;
361        }
362        let stale = entry
363            .metadata()
364            .and_then(|meta| meta.modified())
365            .map(|modified| {
366                SystemTime::now()
367                    .duration_since(modified)
368                    .is_ok_and(|age| age >= STALE_AFTER)
369            })
370            .unwrap_or(false);
371        if stale {
372            let _ = fs::remove_file(entry.path());
373        }
374    }
375}
376
377pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
378    let path = path.as_ref();
379    let mut file = File::open(path).map_err(|source| ConnectionFileError::Io {
380        op: "open",
381        path: path.to_path_buf(),
382        source,
383    })?;
384    // Verify the opened inode, not a separate path lookup: replacing the path
385    // between stat and read must not let an unverified key through.
386    verify_owner_only(path, &file)?;
387    let mut bytes = Vec::new();
388    file.read_to_end(&mut bytes)
389        .map_err(|source| ConnectionFileError::Io {
390            op: "read",
391            path: path.to_path_buf(),
392            source,
393        })?;
394    let info: ConnectionInfo =
395        serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
396            path: path.to_path_buf(),
397            source,
398        })?;
399    info.validate()?;
400    Ok(info)
401}
402
403/// Reads connection information for a client and rejects a declared envelope
404/// version this binary cannot decode before a TCP connection is attempted.
405pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
406    let info = read(path)?;
407    info.validate_wire_version(PROTOCOL_VERSION)?;
408    Ok(info)
409}
410
411/// The paths a reader consults, most specific first. `explicit` is the
412/// caller's own override (a `--subc` flag); `env_named` is the value of
413/// `SUBC_CONNECTION_FILE` read by the caller. Either, when present, is the
414/// ONLY candidate.
415pub fn discovery_candidates(explicit: Option<&Path>, env_named: Option<&OsStr>) -> Vec<PathBuf> {
416    let runtime_dir = non_empty_os_var("XDG_RUNTIME_DIR");
417    let home = non_empty_os_var("HOME");
418    discovery_candidates_with_environment(
419        explicit,
420        env_named,
421        runtime_dir.as_deref(),
422        home.as_deref(),
423        &env::temp_dir(),
424    )
425}
426
427/// Read the reader-side environment and return the first usable connection file.
428/// Unlike writer-side `subc_daemon::bootstrap::connection_file_path()`, this searches
429/// every location where an already-running daemon may have written its file.
430pub fn discover(explicit: Option<&Path>) -> Result<Discovered, DiscoveryError> {
431    let env_named = non_empty_os_var("SUBC_CONNECTION_FILE");
432    discover_candidates(discovery_candidates(explicit, env_named.as_deref()))
433}
434
435fn discovery_candidates_with_environment(
436    explicit: Option<&Path>,
437    env_named: Option<&OsStr>,
438    runtime_dir: Option<&OsStr>,
439    home: Option<&OsStr>,
440    temp_dir: &Path,
441) -> Vec<PathBuf> {
442    if let Some(path) = explicit {
443        return vec![path.to_path_buf()];
444    }
445
446    let env_named = env_named.filter(|value| !value.is_empty());
447    let runtime_dir = runtime_dir.filter(|value| !value.is_empty());
448
449    // SUBC_CONNECTION_FILE names the daemon the caller means, so it is EXCLUSIVE
450    // rather than first-in-a-list. It used to be pushed ahead of the discovery
451    // candidates, which reads as honouring it and is not: a path that is set and
452    // wrong falls through to discovery and answers from whichever daemon is found
453    // -- in practice production. The reply is then true and about the wrong
454    // machine, and every later verdict inherits that while the operator believes
455    // they are reading a rig.
456    //
457    // A fallback is only a hazard where the primary is optional, so removing the
458    // fallback for a deliberately supplied value removes the class. Returning a
459    // single candidate keeps the existing error path: the file is stat-ed, and an
460    // unreadable one is reported as a failure naming that path.
461    if let Some(only) = env_named {
462        return vec![PathBuf::from(only)];
463    }
464
465    let mut candidates = Vec::new();
466    if let Some(runtime_dir) = runtime_dir {
467        push_unique(
468            &mut candidates,
469            PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
470        );
471    }
472    if let Some(home) = home {
473        let mut path = PathBuf::from(home);
474        for part in PROD_CONNECTION_RELATIVE_PATH {
475            path.push(part);
476        }
477        push_unique(&mut candidates, path);
478    }
479    push_unique(
480        &mut candidates,
481        temp_dir.join(format!("subc-{}.connection.json", user_connection_token())),
482    );
483    candidates
484}
485
486fn discover_candidates(candidates: Vec<PathBuf>) -> Result<Discovered, DiscoveryError> {
487    let mut tried = Vec::new();
488    for path in candidates {
489        match read_for_client(&path) {
490            Ok(info) => return Ok(Discovered { path, info }),
491            Err(source) => tried.push(TriedCandidate {
492                path,
493                reason: discovery_reason(&source),
494            }),
495        }
496    }
497    Err(DiscoveryError { tried })
498}
499
500fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
501    if !paths.iter().any(|existing| existing == &path) {
502        paths.push(path);
503    }
504}
505
506fn non_empty_os_var(key: &str) -> Option<OsString> {
507    let value = env::var_os(key)?;
508    if value.is_empty() {
509        None
510    } else {
511        Some(value)
512    }
513}
514
515fn discovery_reason(source: &ConnectionFileError) -> String {
516    match source {
517        ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => {
518            "not found".to_string()
519        }
520        other => other.to_string(),
521    }
522}
523
524/// The per-user component of the temp-fallback connection-file name.
525///
526/// The daemon writer and every reader use this one implementation so a naming
527/// drift cannot make a running daemon appear absent.
528pub fn user_connection_token() -> String {
529    // On Unix the token is the real uid, read from the kernel. Identity must not
530    // depend on a fallible filesystem probe because a transient failure would
531    // make the same user derive a different connection-file name.
532    #[cfg(unix)]
533    {
534        rustix::process::getuid().as_raw().to_string()
535    }
536
537    #[cfg(not(unix))]
538    {
539        for key in ["USER", "USERNAME", "HOME", "USERPROFILE"] {
540            if let Some(value) = non_empty_os_var(key) {
541                return sanitize_token(&value.to_string_lossy());
542            }
543        }
544
545        "unknown".to_string()
546    }
547}
548
549#[cfg(not(unix))]
550fn sanitize_token(raw: &str) -> String {
551    let mut token = String::new();
552    for ch in raw.chars() {
553        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
554            token.push(ch);
555        } else {
556            token.push('_');
557        }
558    }
559    if token.is_empty() {
560        "unknown".to_string()
561    } else {
562        token
563    }
564}
565
566#[cfg(unix)]
567fn verify_owner_only(path: &Path, file: &File) -> Result<(), ConnectionFileError> {
568    let meta = file.metadata().map_err(|source| ConnectionFileError::Io {
569        op: "fstat",
570        path: path.to_path_buf(),
571        source,
572    })?;
573    verify_owner_only_for_uid(path, &meta, rustix::process::geteuid().as_raw())
574}
575
576#[cfg(unix)]
577fn verify_owner_only_for_uid(
578    path: &Path,
579    meta: &fs::Metadata,
580    uid: u32,
581) -> Result<(), ConnectionFileError> {
582    use std::os::unix::fs::{MetadataExt, PermissionsExt};
583    // A foreign owner can substitute their own endpoint and key even at 0600.
584    // This also refuses a foreign-owned file readable through an ACL or by root.
585    if meta.uid() != uid {
586        return Err(ConnectionFileError::Invalid {
587            reason: format!(
588                "connection file {} is owned by uid {}, expected effective uid {uid}",
589                path.display(),
590                meta.uid()
591            ),
592        });
593    }
594    let mode = meta.permissions().mode();
595    // Any group or other permission bit means the key is exposed beyond the owner.
596    if mode & 0o077 != 0 {
597        return Err(ConnectionFileError::InsecurePermissions {
598            path: path.to_path_buf(),
599            mode: mode & 0o777,
600        });
601    }
602    Ok(())
603}
604
605#[cfg(not(unix))]
606fn verify_owner_only(_path: &Path, _file: &File) -> Result<(), ConnectionFileError> {
607    // On Windows the file inherits the per-user profile directory's ACL (owner,
608    // SYSTEM, Administrators only) at create time; see open_owner_only_new. There
609    // are no portable Unix mode bits to re-check on read here.
610    Ok(())
611}
612
613pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
614    let mut key = vec![0u8; KEY_LEN];
615    getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
616    Ok(key)
617}
618
619pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
620    let mut daemon_id = [0u8; DAEMON_ID_LEN];
621    getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
622    Ok(daemon_id)
623}
624
625fn write_atomic_inner(
626    path: &Path,
627    temp_path: &Path,
628    info: &ConnectionInfo,
629) -> Result<(), ConnectionFileError> {
630    let json =
631        serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
632            path: path.to_path_buf(),
633            source,
634        })?;
635
636    {
637        let mut file =
638            open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
639                op: "create_temp",
640                path: temp_path.to_path_buf(),
641                source,
642            })?;
643        file.write_all(&json)
644            .and_then(|()| file.sync_all())
645            .map_err(|source| ConnectionFileError::Io {
646                op: "write_temp",
647                path: temp_path.to_path_buf(),
648                source,
649            })?;
650    }
651
652    fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
653        op: "rename",
654        path: path.to_path_buf(),
655        source,
656    })?;
657    Ok(())
658}
659
660fn open_owner_only_new(path: &Path) -> io::Result<File> {
661    let mut options = OpenOptions::new();
662    options.write(true).create_new(true);
663    #[cfg(unix)]
664    {
665        use std::os::unix::fs::OpenOptionsExt;
666        options.mode(0o600);
667    }
668    #[cfg(windows)]
669    {
670        // No explicit DACL is set: the connection file is published under the
671        // per-user profile (XDG_RUNTIME_DIR is unset on Windows, so
672        // connection_file_path() falls back to %TEMP% =
673        // %LOCALAPPDATA%\Temp). That directory's inherited ACL already grants
674        // access to only the owning user, SYSTEM, and Administrators — so the
675        // same-host, non-admin attacker (the threat 0600 guards against on the
676        // world-readable Unix /tmp) cannot read the key here. Administrators can
677        // read any file (SeBackup/SeTakeOwnership) on either platform and are
678        // out of scope for a same-host secret. Revisit an explicit owner-only
679        // SECURITY_DESCRIPTOR only if the connection file ever moves off the
680        // per-user profile directory.
681    }
682    options.open(path)
683}
684
685fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
686    let mut suffix = [0u8; 16];
687    getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
688    let file_name = file_name.to_string_lossy();
689    Ok(parent.join(format!(
690        ".{file_name}.{}.{}.tmp",
691        process::id(),
692        hex(&suffix)
693    )))
694}
695
696fn hex(bytes: &[u8]) -> String {
697    const HEX: &[u8; 16] = b"0123456789abcdef";
698    let mut out = String::with_capacity(bytes.len() * 2);
699    for byte in bytes {
700        out.push(HEX[(byte >> 4) as usize] as char);
701        out.push(HEX[(byte & 0x0f) as usize] as char);
702    }
703    out
704}
705
706impl fmt::Display for ConnectionFileError {
707    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
708        match self {
709            Self::MissingParent { path } => {
710                write!(f, "connection file path has no parent: {}", path.display())
711            }
712            Self::MissingFileName { path } => {
713                write!(
714                    f,
715                    "connection file path has no file name: {}",
716                    path.display()
717                )
718            }
719            Self::Io { op, path, source } => write!(
720                f,
721                "connection file {op} failed for {}: {source}",
722                path.display()
723            ),
724            Self::JsonRead { path, source } => write!(
725                f,
726                "connection file JSON read failed for {}: {source}",
727                path.display()
728            ),
729            Self::JsonWrite { path, source } => write!(
730                f,
731                "connection file JSON write failed for {}: {source}",
732                path.display()
733            ),
734            Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
735            Self::UnsupportedSchema { schema, supported } => write!(
736                f,
737                "unsupported connection file schema {schema}; expected {supported}"
738            ),
739            Self::WireVersionMismatch { file, supported } => write!(
740                f,
741                "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
742            ),
743            Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
744            Self::KeyTooShort { len, min } => write!(
745                f,
746                "connection file key is too short: {len} bytes, need at least {min}"
747            ),
748            Self::InsecurePermissions { path, mode } => write!(
749                f,
750                "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
751                path.display()
752            ),
753            Self::InsecureParentDirectory { component, mode } => write!(
754                f,
755                "refusing to publish the connection file: ancestor {} is mode {mode:#o}, \
756                 which lets another user replace the file regardless of its own 0600 mode; \
757                 this is a misconfiguration check, not a defence against a same-uid caller",
758                component.display()
759            ),
760        }
761    }
762}
763
764impl Error for ConnectionFileError {
765    fn source(&self) -> Option<&(dyn Error + 'static)> {
766        match self {
767            Self::Io { source, .. } => Some(source),
768            Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
769            Self::Random(_) => None,
770            Self::MissingParent { .. }
771            | Self::MissingFileName { .. }
772            | Self::InsecureParentDirectory { .. }
773            | Self::UnsupportedSchema { .. }
774            | Self::WireVersionMismatch { .. }
775            | Self::Invalid { .. }
776            | Self::KeyTooShort { .. }
777            | Self::InsecurePermissions { .. } => None,
778        }
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use subc_test_support::TestTempDir;
786
787    fn sample_info() -> ConnectionInfo {
788        ConnectionInfo {
789            schema: SCHEMA_VERSION,
790            wire_version: None,
791            endpoints: vec![Endpoint {
792                host: "127.0.0.1".to_owned(),
793                port: 8799,
794            }],
795            key: vec![0xABu8; KEY_LEN],
796            daemon_id: [0x11u8; DAEMON_ID_LEN],
797            pid: 4242,
798            daemon_ver: "subc-test".to_owned(),
799        }
800    }
801
802    fn unique_temp_path() -> PathBuf {
803        let mut suffix = [0u8; 8];
804        getrandom::getrandom(&mut suffix).expect("random suffix");
805        let mut name = String::from("subc-connfile-test-");
806        for byte in suffix {
807            name.push_str(&format!("{byte:02x}"));
808        }
809        name.push_str(".json");
810        std::env::temp_dir().join(name)
811    }
812
813    fn unique_temp_dir(label: &str) -> TestTempDir {
814        TestTempDir::new(label)
815    }
816
817    #[cfg(unix)]
818    #[test]
819    fn a_foreign_owned_ancestor_refuses_even_with_mode_0755() {
820        use std::os::unix::fs::{MetadataExt, PermissionsExt};
821        let root = unique_temp_dir("foreign-ancestor");
822        let parent = root.join("run");
823        fs::create_dir(&parent).unwrap();
824        fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
825        // Model a root writer inspecting an ordinary user's directory. The walk
826        // uses actual filesystem metadata, without changing the test process uid.
827        if fs::metadata(&parent).unwrap().uid() == 0 {
828            assert!(process::Command::new("chown")
829                .args(["65534"])
830                .arg(&parent)
831                .status()
832                .unwrap()
833                .success());
834        }
835        assert_ne!(fs::metadata(&parent).unwrap().uid(), 0);
836        assert!(
837            refuse_writable_ancestor_for_uid(&parent, 0).is_err(),
838            "0755 does not prevent its owner replacing the key"
839        );
840        if rustix::process::geteuid().as_raw() != 0 {
841            assert!(
842                refuse_writable_ancestor(&parent).is_ok(),
843                "the same directory is safe for its actual owner"
844            );
845        }
846    }
847
848    #[cfg(unix)]
849    #[test]
850    fn owner_only_file_validation_refuses_a_different_effective_uid() {
851        use std::os::unix::fs::{MetadataExt, PermissionsExt};
852        let root = unique_temp_dir("foreign-file");
853        let path = root.join(CONNECTION_FILE_NAME);
854        write_atomic(&path, &sample_info()).unwrap();
855        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
856        let owner = fs::metadata(&path).unwrap().uid();
857        let meta = fs::metadata(&path).unwrap();
858        assert!(verify_owner_only_for_uid(&path, &meta, owner).is_ok());
859        let different_uid = if owner == 0 { 1 } else { 0 };
860        assert!(
861            matches!(
862                verify_owner_only_for_uid(&path, &meta, different_uid),
863                Err(ConnectionFileError::Invalid { .. })
864            ),
865            "0600 is not proof that the file belongs to the reader"
866        );
867        assert_eq!(read(&path).unwrap(), sample_info());
868    }
869
870    /// A GROUP-writable ancestor must refuse, and the passing control on the same
871    /// fixture minus the group bit is what makes this test discriminate.
872    ///
873    /// Both arms are here because the refusal MESSAGE is not the property: a test
874    /// matching on message text passes through a removed check whenever the
875    /// wording survives, which is how the adjacent guard in claustrum kept three
876    /// green tests while examining only `0o002`. The arms differ by one bit on
877    /// one directory and nothing else.
878    #[cfg(unix)]
879    #[test]
880    fn a_group_writable_ancestor_refuses_and_the_same_tree_without_the_bit_publishes() {
881        use std::os::unix::fs::PermissionsExt;
882
883        for (ancestor_mode, expect_refusal) in [(0o770, true), (0o750, false)] {
884            let root = unique_temp_dir(&format!("ancestor-{ancestor_mode:o}"));
885            let ancestor = root.join("ancestor");
886            let leaf = ancestor.join("run");
887            fs::create_dir_all(&leaf).expect("create leaf");
888            fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700))
889                .expect("tighten the leaf so only the ancestor differs");
890            fs::set_permissions(&ancestor, fs::Permissions::from_mode(ancestor_mode))
891                .expect("set ancestor mode");
892
893            let result = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
894
895            match (expect_refusal, result) {
896                (true, Err(ConnectionFileError::InsecureParentDirectory { component, mode })) => {
897                    let expected = ancestor.canonicalize().unwrap_or_else(|_| ancestor.clone());
898                    assert_eq!(component, expected);
899                    assert_eq!(mode & 0o020, 0o020, "the group bit is what refused");
900                }
901                (true, other) => panic!("a group-writable ancestor must refuse, got {other:?}"),
902                (false, Ok(())) => {}
903                (false, other) => panic!("0o750 is not writable by another user: {other:?}"),
904            }
905
906            let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
907            let _ = fs::remove_dir_all(root);
908        }
909    }
910
911    /// `/tmp` is 1777 by design. Without the sticky exemption this check fires on
912    /// every correctly-configured system, and a check that refuses healthy
913    /// configuration gets disabled -- after which it protects nothing.
914    #[cfg(unix)]
915    #[test]
916    fn a_sticky_world_writable_ancestor_publishes() {
917        use std::os::unix::fs::PermissionsExt;
918
919        let root = unique_temp_dir("ancestor-sticky");
920        let ancestor = root.join("sticky");
921        let leaf = ancestor.join("run");
922        fs::create_dir_all(&leaf).expect("create leaf");
923        fs::set_permissions(&leaf, fs::Permissions::from_mode(0o700)).expect("tighten leaf");
924        fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o1777)).expect("sticky 1777");
925
926        let published = write_atomic(leaf.join(CONNECTION_FILE_NAME), &sample_info());
927        assert!(
928            published.is_ok(),
929            "a sticky 1777 ancestor is /tmp's own shape and must publish: {published:?}"
930        );
931
932        let _ = fs::set_permissions(&ancestor, fs::Permissions::from_mode(0o700));
933        let _ = fs::remove_dir_all(root);
934    }
935
936    /// The daemon owns this directory, so the misconfiguration is unproducible
937    /// rather than merely reported when the directory does not yet exist.
938    #[cfg(unix)]
939    #[test]
940    fn an_absent_parent_is_created_owner_only() {
941        use std::os::unix::fs::PermissionsExt;
942
943        let root = unique_temp_dir("absent-parent");
944        let parent = root.join("run");
945        assert!(!parent.exists(), "fixture must start with no parent");
946
947        write_atomic(parent.join(CONNECTION_FILE_NAME), &sample_info()).expect("publishes");
948
949        let mode = fs::metadata(&parent)
950            .expect("parent exists")
951            .permissions()
952            .mode()
953            & 0o777;
954        assert_eq!(mode, 0o700, "an absent parent is created owner-only");
955
956        let _ = fs::remove_dir_all(root);
957    }
958
959    fn prod_connection_file(home: &Path) -> PathBuf {
960        let mut path = home.to_path_buf();
961        for part in PROD_CONNECTION_RELATIVE_PATH {
962            path.push(part);
963        }
964        path
965    }
966
967    #[test]
968    fn an_explicit_path_is_the_only_discovery_candidate() {
969        let explicit = PathBuf::from("/rig/explicit.json");
970        let env_named = OsStr::new("/rig/from-env.json");
971
972        assert_eq!(
973            discovery_candidates(Some(&explicit), Some(env_named)),
974            vec![explicit],
975            "the caller's explicit override must exclude every fallback"
976        );
977    }
978
979    #[test]
980    fn set_and_wrong_environment_path_fails_without_fallback() {
981        let root = unique_temp_dir("env-exclusive");
982        let runtime = root.join("runtime");
983        let home = root.join("home");
984        let temp = root.join("temp");
985        let named = root.join("missing-rig.json");
986        let production = prod_connection_file(&home);
987        fs::create_dir_all(production.parent().expect("production parent"))
988            .expect("create production parent");
989        write_atomic(&production, &sample_info()).expect("write discoverable production file");
990
991        assert_eq!(
992            discovery_candidates(None, Some(named.as_os_str())),
993            vec![named.clone()],
994            "a named connection file must not be followed by discovery paths"
995        );
996        let candidates = discovery_candidates_with_environment(
997            None,
998            Some(named.as_os_str()),
999            Some(runtime.as_os_str()),
1000            Some(home.as_os_str()),
1001            &temp,
1002        );
1003        let error = discover_candidates(candidates)
1004            .expect_err("set-and-wrong SUBC_CONNECTION_FILE must fail rather than use production");
1005
1006        assert_eq!(
1007            error.tried,
1008            vec![TriedCandidate {
1009                path: named.clone(),
1010                reason: "not found".to_owned(),
1011            }],
1012            "the named rig path must be the only attempted file"
1013        );
1014        assert!(
1015            error.to_string().contains(&named.display().to_string()),
1016            "the failure must name the operator-selected rig path"
1017        );
1018        drop(root);
1019    }
1020
1021    #[test]
1022    fn empty_environment_paths_are_unset_and_fallback_candidates_are_absolute() {
1023        let root = unique_temp_dir("empty-candidates");
1024        let runtime = root.join("runtime");
1025        let home = root.join("home");
1026        let temp = root.join("temp");
1027        let empty = OsStr::new("");
1028
1029        let without_named_override = discovery_candidates_with_environment(
1030            None,
1031            None,
1032            Some(runtime.as_os_str()),
1033            Some(home.as_os_str()),
1034            &temp,
1035        );
1036        let with_empty_named_override = discovery_candidates_with_environment(
1037            None,
1038            Some(empty),
1039            Some(runtime.as_os_str()),
1040            Some(home.as_os_str()),
1041            &temp,
1042        );
1043        assert_eq!(with_empty_named_override, without_named_override);
1044
1045        let without_runtime =
1046            discovery_candidates_with_environment(None, None, None, Some(home.as_os_str()), &temp);
1047        let with_empty_runtime = discovery_candidates_with_environment(
1048            None,
1049            None,
1050            Some(empty),
1051            Some(home.as_os_str()),
1052            &temp,
1053        );
1054        assert_eq!(with_empty_runtime, without_runtime);
1055        assert!(
1056            with_empty_named_override.iter().all(|path| path.is_absolute())
1057                && with_empty_runtime.iter().all(|path| path.is_absolute()),
1058            "every fallback candidate must be absolute: {with_empty_named_override:?} {with_empty_runtime:?}"
1059        );
1060
1061        drop(root);
1062    }
1063
1064    #[test]
1065    fn discovery_without_overrides_keeps_three_rung_order_and_deduplicates() {
1066        let root = unique_temp_dir("candidate-order");
1067        let runtime = root.join("runtime");
1068        let home = root.join("home");
1069        let temp = root.join("temp");
1070
1071        let candidates = discovery_candidates_with_environment(
1072            None,
1073            None,
1074            Some(runtime.as_os_str()),
1075            Some(home.as_os_str()),
1076            &temp,
1077        );
1078        assert_eq!(
1079            candidates,
1080            vec![
1081                runtime.join(CONNECTION_FILE_NAME),
1082                prod_connection_file(&home),
1083                temp.join(format!("subc-{}.connection.json", user_connection_token())),
1084            ],
1085            "readers must try runtime, production, then the per-user temp fallback"
1086        );
1087
1088        let production = prod_connection_file(&home);
1089        let production_dir = production.parent().expect("production directory");
1090        let deduplicated = discovery_candidates_with_environment(
1091            None,
1092            None,
1093            Some(production_dir.as_os_str()),
1094            Some(home.as_os_str()),
1095            &temp,
1096        );
1097        assert_eq!(
1098            deduplicated,
1099            vec![
1100                production,
1101                temp.join(format!("subc-{}.connection.json", user_connection_token())),
1102            ],
1103            "one path reached through two rungs must only be tried once"
1104        );
1105        drop(root);
1106    }
1107
1108    #[test]
1109    fn discover_on_a_temp_home_returns_the_parsed_production_file() {
1110        const CHILD_MARKER: &str = "SUBC_TRANSPORT_DISCOVERY_CHILD_EXPECTED";
1111        if let Some(expected) = env::var_os(CHILD_MARKER) {
1112            let expected = PathBuf::from(expected);
1113            let discovered = discover(None).expect("discover production connection file");
1114            assert_eq!(discovered.path, expected);
1115            assert_eq!(discovered.info, sample_info());
1116            return;
1117        }
1118
1119        let root = unique_temp_dir("discover-home");
1120        let home = root.join("home");
1121        let temp = root.join("temp");
1122        fs::create_dir_all(&temp).expect("create child temp directory");
1123        let production = prod_connection_file(&home);
1124        fs::create_dir_all(production.parent().expect("production parent"))
1125            .expect("create production parent");
1126        write_atomic(&production, &sample_info()).expect("write production connection file");
1127
1128        // Run the public environment-reading API in a child so this test does not
1129        // mutate process-global environment while sibling tests execute.
1130        let output = process::Command::new(env::current_exe().expect("current test executable"))
1131            .args([
1132                "--exact",
1133                "connection_file::tests::discover_on_a_temp_home_returns_the_parsed_production_file",
1134                "--nocapture",
1135            ])
1136            .env(CHILD_MARKER, &production)
1137            .env_remove("SUBC_CONNECTION_FILE")
1138            .env_remove("XDG_RUNTIME_DIR")
1139            .env("HOME", &home)
1140            .env("TMPDIR", &temp)
1141            .env("TMP", &temp)
1142            .env("TEMP", &temp)
1143            .output()
1144            .expect("run isolated discovery child");
1145        assert!(
1146            output.status.success(),
1147            "discovery child failed\nstdout:\n{}\nstderr:\n{}",
1148            String::from_utf8_lossy(&output.stdout),
1149            String::from_utf8_lossy(&output.stderr)
1150        );
1151        drop(root);
1152    }
1153
1154    #[test]
1155    fn write_atomic_sweeps_stale_temps_and_spares_recent_and_unrelated_files() {
1156        let dir = TestTempDir::new("subc-sweep");
1157        let target = dir.join("subc-connection.json");
1158
1159        // A temp stranded by a dead writer: correct shape, old enough to sweep.
1160        let stale = dir.join(".subc-connection.json.99999.deadbeef.tmp");
1161        fs::write(&stale, b"stranded").expect("write stale");
1162        let old = SystemTime::now() - Duration::from_secs(3600);
1163        File::options()
1164            .write(true)
1165            .open(&stale)
1166            .expect("open stale")
1167            .set_modified(old)
1168            .expect("backdate stale");
1169
1170        // A temp from a writer that may still be mid-rename: same shape, fresh.
1171        // Sweeping this would race a concurrent publish.
1172        let recent = dir.join(".subc-connection.json.99998.feedface.tmp");
1173        fs::write(&recent, b"in flight").expect("write recent");
1174
1175        // An old file that is not one of our temps. Age alone must not condemn it.
1176        let unrelated = dir.join("unrelated.txt");
1177        fs::write(&unrelated, b"not ours").expect("write unrelated");
1178        File::options()
1179            .write(true)
1180            .open(&unrelated)
1181            .expect("open unrelated")
1182            .set_modified(old)
1183            .expect("backdate unrelated");
1184
1185        write_atomic(&target, &sample_info()).expect("publish");
1186
1187        assert!(!stale.exists(), "a stale temp must be swept");
1188        assert!(
1189            recent.exists(),
1190            "a recent temp may belong to an in-flight publish and must be spared"
1191        );
1192        assert!(
1193            unrelated.exists(),
1194            "age alone must not condemn a file that is not one of our temps"
1195        );
1196        assert!(target.exists(), "the publish itself must still land");
1197    }
1198
1199    #[test]
1200    fn debug_redacts_key_bytes() {
1201        let info = sample_info();
1202        let rendered = format!("{info:?}");
1203        assert!(
1204            rendered.contains("redacted"),
1205            "Debug must mark the key as redacted: {rendered}"
1206        );
1207        // The raw key byte pattern (0xab) must not appear anywhere in the output.
1208        assert!(
1209            !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
1210            "Debug must not leak raw key bytes: {rendered}"
1211        );
1212    }
1213
1214    #[test]
1215    fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
1216        let mut unsupported_schema = sample_info();
1217        unsupported_schema.schema = SCHEMA_VERSION + 1;
1218        let before = unsupported_schema.clone();
1219        let err = unsupported_schema
1220            .validate()
1221            .expect_err("unsupported schema must be rejected");
1222        assert!(matches!(
1223            err,
1224            ConnectionFileError::UnsupportedSchema {
1225                schema,
1226                supported: SCHEMA_VERSION,
1227            } if schema == SCHEMA_VERSION + 1
1228        ));
1229        assert_eq!(unsupported_schema, before, "validate must not mutate input");
1230
1231        let mut empty_endpoints = sample_info();
1232        empty_endpoints.endpoints.clear();
1233        let before = empty_endpoints.clone();
1234        let err = empty_endpoints
1235            .validate()
1236            .expect_err("empty endpoint list must be rejected");
1237        assert!(matches!(
1238            err,
1239            ConnectionFileError::Invalid { ref reason }
1240                if reason == "connection file must include at least one endpoint"
1241        ));
1242        assert_eq!(empty_endpoints, before, "validate must not mutate input");
1243
1244        let mut short_key = sample_info();
1245        short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
1246        let before = short_key.clone();
1247        let err = short_key
1248            .validate()
1249            .expect_err("short key must be rejected");
1250        assert!(matches!(
1251            err,
1252            ConnectionFileError::KeyTooShort {
1253                len,
1254                min: MIN_KEY_LEN,
1255            } if len == MIN_KEY_LEN - 1
1256        ));
1257        assert_eq!(short_key, before, "validate must not mutate input");
1258    }
1259
1260    #[test]
1261    fn optional_wire_version_round_trips() {
1262        let path = unique_temp_path();
1263        let legacy = sample_info();
1264        write_atomic(&path, &legacy).expect("write legacy connection file");
1265        let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
1266        assert!(!legacy_json.contains("wire_version"));
1267        assert_eq!(
1268            read_for_client(&path).expect("legacy file remains readable"),
1269            legacy
1270        );
1271
1272        let mut current = sample_info();
1273        current.wire_version = Some(PROTOCOL_VERSION);
1274        write_atomic(&path, &current).expect("write current connection file");
1275        let current_json = fs::read_to_string(&path).expect("read current connection file");
1276        let current_json: serde_json::Value =
1277            serde_json::from_str(&current_json).expect("parse current connection file");
1278        assert_eq!(
1279            current_json["wire_version"].as_u64(),
1280            Some(u64::from(PROTOCOL_VERSION))
1281        );
1282        assert_eq!(
1283            read_for_client(&path).expect("current file is readable"),
1284            current
1285        );
1286        let _ = fs::remove_file(&path);
1287    }
1288
1289    #[test]
1290    fn read_for_client_rejects_mismatched_wire_version() {
1291        let path = unique_temp_path();
1292        let mut info = sample_info();
1293        let file_version = PROTOCOL_VERSION + 1;
1294        info.wire_version = Some(file_version);
1295        write_atomic(&path, &info).expect("write mismatched connection file");
1296
1297        let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
1298        assert!(matches!(
1299            err,
1300            ConnectionFileError::WireVersionMismatch { file, supported }
1301                if file == file_version && supported == PROTOCOL_VERSION
1302        ));
1303        let rendered = err.to_string();
1304        assert!(rendered.contains(&file_version.to_string()));
1305        assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
1306        assert!(rendered.contains("binary must be upgraded"));
1307        let _ = fs::remove_file(&path);
1308    }
1309
1310    #[cfg(unix)]
1311    #[test]
1312    fn read_rejects_group_or_world_readable_file() {
1313        use std::os::unix::fs::PermissionsExt;
1314
1315        let path = unique_temp_path();
1316        write_atomic(&path, &sample_info()).expect("write owner-only file");
1317        // Loosen permissions as if the key leaked to other local users.
1318        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
1319
1320        let err = read(&path).expect_err("group/world-readable key file must be rejected");
1321        assert!(
1322            matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
1323            "expected InsecurePermissions, got {err:?}"
1324        );
1325        let _ = fs::remove_file(&path);
1326    }
1327}