Skip to main content

sqry_core/persistence/
path_safety.rs

1//! Path-safety validation for sqry's on-disk persistence write/read paths.
2//!
3//! # Overview
4//!
5//! [`validate_path_in_workspace`] enforces the workspace-containment and
6//! no-symlink contract that all callers writing or reading derived-cache files
7//! must satisfy. The function is intentionally self-contained within
8//! `sqry-core` so that crates such as `sqry-db` can depend on it without
9//! pulling in MCP-specific code.
10//!
11//! # Security model
12//!
13//! sqry writes data derived from the user's workspace onto disk. Allowing a
14//! crafted symlink inside the workspace to redirect those writes to an
15//! arbitrary path would be a classic path-traversal / TOCTOU vulnerability.
16//! This helper prevents that by:
17//!
18//! 1. **Join-before-canonicalize**: Relative paths are joined against the
19//!    canonical workspace root before any `canonicalize` call. This matches
20//!    the caller-boundary pattern used in `sqry-mcp` (see
21//!    `sqry-mcp/src/engine.rs:457`).
22//! 2. **Parent-only canonicalize**: The target itself may not exist yet
23//!    (first save on a fresh workspace). We canonicalize the *parent* and
24//!    reconstruct the full path, avoiding `canonicalize` failures on missing
25//!    files.
26//! 3. **Descendant check**: The resulting canonical path must start with the
27//!    canonical workspace root. Pure-path prefix matching prevents `..`
28//!    escapes that survive canonicalization.
29//! 4. **Symlink rejection on target**: `symlink_metadata` (lstat) is used;
30//!    we never follow symlinks on the final file component.
31//! 5. **Symlink rejection on ancestors**: Every directory component between
32//!    the canonical file path and the canonical workspace root is checked with
33//!    `symlink_metadata`. A symlink anywhere in that chain is rejected.
34
35use std::path::{Path, PathBuf};
36
37// ─────────────────────────────────────────────────────────────────────────────
38// Error type
39// ─────────────────────────────────────────────────────────────────────────────
40
41/// Error variants returned by [`validate_path_in_workspace`].
42#[derive(Debug)]
43pub enum PathSafetyError {
44    /// The canonicalized path is not a descendant of the workspace root.
45    ///
46    /// This is triggered by absolute paths that escape the workspace, or by
47    /// relative paths containing enough `..` components to escape after
48    /// joining.
49    OutsideWorkspace {
50        /// The resolved path that was found to be outside the workspace.
51        path: PathBuf,
52        /// The canonical workspace root used for the comparison.
53        workspace_root: PathBuf,
54    },
55
56    /// The final path component is a symlink (detected via `symlink_metadata`;
57    /// the symlink is NOT followed).
58    ///
59    /// sqry refuses to write to or read from symlink targets to prevent
60    /// TOCTOU races and silent redirection of persistence data.
61    SymlinkTarget {
62        /// The path whose final component is a symlink.
63        path: PathBuf,
64    },
65
66    /// A directory ancestor of the path — between the path and the workspace
67    /// root — is a symlink.
68    ///
69    /// An attacker controlling a symlinked ancestor directory could redirect
70    /// all writes inside that subtree to an arbitrary location.
71    SymlinkInAncestor {
72        /// The ancestor directory path that was found to be a symlink.
73        ancestor: PathBuf,
74    },
75
76    /// A transparent wrapper around a [`std::io::Error`].
77    Io(std::io::Error),
78}
79
80impl std::fmt::Display for PathSafetyError {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::OutsideWorkspace {
84                path,
85                workspace_root,
86            } => write!(
87                f,
88                "path '{}' is outside workspace root '{}'",
89                path.display(),
90                workspace_root.display(),
91            ),
92            Self::SymlinkTarget { path } => write!(
93                f,
94                "path '{}' is a symlink; sqry refuses to follow symlinks on persistence paths",
95                path.display(),
96            ),
97            Self::SymlinkInAncestor { ancestor } => write!(
98                f,
99                "ancestor directory '{}' is a symlink; \
100                 all ancestor directories up to the workspace root must be real directories",
101                ancestor.display(),
102            ),
103            Self::Io(e) => write!(f, "I/O error during path validation: {e}"),
104        }
105    }
106}
107
108impl std::error::Error for PathSafetyError {
109    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
110        match self {
111            Self::Io(e) => Some(e),
112            _ => None,
113        }
114    }
115}
116
117impl From<std::io::Error> for PathSafetyError {
118    fn from(e: std::io::Error) -> Self {
119        Self::Io(e)
120    }
121}
122
123// ─────────────────────────────────────────────────────────────────────────────
124// Public function
125// ─────────────────────────────────────────────────────────────────────────────
126
127/// Validate that `path` is safe to open for read/write inside `workspace_root`.
128///
129/// # Validation steps
130///
131/// 1. If `path` is relative, join it against `workspace_root` first. This
132///    is the **caller-boundary** pattern: all paths are resolved in the context
133///    of the workspace, not the process working directory.
134/// 2. Canonicalize `workspace_root` (it must exist on disk).
135/// 3. **Pre-canonicalization ancestor symlink scan**: walk the raw joined path
136///    component-by-component (starting after the workspace root prefix), and
137///    for each directory component call `symlink_metadata`. If any component
138///    is a symlink, return `Err(SymlinkInAncestor)`. This check must happen
139///    *before* `canonicalize` resolves symlinks away.
140/// 4. Canonicalize the **parent** of the joined path. The target itself may
141///    not exist yet (legitimate for `save_derived` on a fresh workspace). If
142///    the parent does not exist, `Err(Io(...))` is returned — callers are
143///    responsible for creating the parent directory first.
144/// 5. Reconstruct `canonical_path = canonical_parent.join(file_name)`.
145/// 6. Confirm `canonical_path` starts with `canonical_workspace_root`
146///    (descendant check). If not, return `Err(OutsideWorkspace)`.
147/// 7. If the target currently exists, call `symlink_metadata` on it. If it is
148///    a symlink, return `Err(SymlinkTarget)`.
149///
150/// On success returns the fully resolved `canonical_path` (parent
151/// canonicalized + file name appended). The caller can use this for all
152/// subsequent I/O.
153///
154/// # Errors
155///
156/// Returns [`PathSafetyError`] in any of the documented failure cases.
157pub fn validate_path_in_workspace(
158    path: &Path,
159    workspace_root: &Path,
160) -> Result<PathBuf, PathSafetyError> {
161    // ── Step 1: Resolve relative paths against the workspace root ────────────
162    //
163    // Do NOT use the process CWD — all paths are in the context of the
164    // workspace. This is the caller-boundary pattern from engine.rs:457.
165    let joined = if path.is_absolute() {
166        path.to_path_buf()
167    } else {
168        workspace_root.join(path)
169    };
170
171    // ── Step 2: Canonicalize the workspace root ───────────────────────────────
172    let canonical_ws = workspace_root.canonicalize()?;
173
174    // ── Step 3: Pre-canonicalization ancestor symlink scan ───────────────────
175    //
176    // `canonicalize` follows symlinks, so after canonicalization the path
177    // components no longer reflect the raw filesystem structure — symlinks
178    // are silently resolved. We MUST scan for symlinked ancestors on the raw
179    // (pre-canonical) path BEFORE calling canonicalize.
180    //
181    // We walk the component chain incrementally:
182    //   workspace_root / c1 / c2 / ... / cN-1  (all except the final filename)
183    // For each accumulated prefix, call `symlink_metadata` on it. If any
184    // prefix is a symlink, reject immediately.
185    //
186    // The scan begins after the workspace root itself (the root is trusted).
187    {
188        // Collect all ancestor components that lie between workspace_root and
189        // the parent of `joined`. We build them up incrementally.
190        let parent = joined.parent().ok_or_else(|| {
191            std::io::Error::new(
192                std::io::ErrorKind::InvalidInput,
193                format!(
194                    "validate_path_in_workspace: path has no parent component: {}",
195                    joined.display()
196                ),
197            )
198        })?;
199
200        // Strip the workspace_root prefix from `parent` to get the relative
201        // sub-path. If `joined` is absolute and shares no prefix with
202        // workspace_root we will catch it in step 6 (outside workspace check).
203        // For now, proceed with whatever sub-components we have.
204        let sub_path = parent.strip_prefix(workspace_root).unwrap_or(parent);
205
206        // Walk the sub-path components, building incremental prefixes rooted
207        // at `workspace_root`.
208        let mut cursor = workspace_root.to_path_buf();
209        for component in sub_path.components() {
210            cursor.push(component);
211
212            // `symlink_metadata` is lstat — it sees the link node itself,
213            // not the target. If `cursor` does not exist yet, `symlink_metadata`
214            // returns `NotFound` which we treat as "not a symlink" (the
215            // ancestor simply hasn't been created; that will be caught later
216            // when canonicalize fails).
217            match std::fs::symlink_metadata(&cursor) {
218                Ok(meta) if meta.file_type().is_symlink() => {
219                    return Err(PathSafetyError::SymlinkInAncestor { ancestor: cursor });
220                }
221                // Not found or other IO error: skip the symlink check for
222                // this component. A missing ancestor will surface as an IO
223                // error in the canonicalize step below.
224                _ => {}
225            }
226        }
227    }
228
229    // ── Step 4: Canonicalize the PARENT of the joined path ───────────────────
230    //
231    // We intentionally do NOT canonicalize `joined` itself because the target
232    // file may not exist yet (first save on a fresh workspace). Canonicalizing
233    // a non-existing path fails on POSIX. Instead, canonicalize the parent
234    // directory, which MUST exist, and then re-attach the file name.
235    let parent = joined.parent().ok_or_else(|| {
236        std::io::Error::new(
237            std::io::ErrorKind::InvalidInput,
238            format!(
239                "validate_path_in_workspace: path has no parent component: {}",
240                joined.display()
241            ),
242        )
243    })?;
244
245    let canonical_parent = parent.canonicalize().map_err(|e| {
246        std::io::Error::new(
247            e.kind(),
248            format!(
249                "validate_path_in_workspace: cannot canonicalize parent directory '{}': {e}",
250                parent.display()
251            ),
252        )
253    })?;
254
255    // ── Step 5: Reconstruct the full canonical path ───────────────────────────
256    let file_name = joined.file_name().ok_or_else(|| {
257        std::io::Error::new(
258            std::io::ErrorKind::InvalidInput,
259            format!(
260                "validate_path_in_workspace: path has no file name component: {}",
261                joined.display()
262            ),
263        )
264    })?;
265    let canonical_path = canonical_parent.join(file_name);
266
267    // ── Step 6: Descendant (workspace-containment) check ─────────────────────
268    //
269    // Use `starts_with` on the canonical paths. This is safe after
270    // canonicalization because both paths are fully resolved (no `..`, no
271    // symlinks up to this point) and use the OS-native separator.
272    if !canonical_path.starts_with(&canonical_ws) {
273        return Err(PathSafetyError::OutsideWorkspace {
274            path: canonical_path,
275            workspace_root: canonical_ws,
276        });
277    }
278
279    // ── Step 7: Reject if the target itself is a symlink ─────────────────────
280    //
281    // `symlink_metadata` does NOT follow the symlink, so we see the link node
282    // itself. Only check if the path exists (non-existent targets are fine —
283    // they will be created fresh).
284    //
285    // Note: we check the raw `joined` path here (not `canonical_path`) because
286    // the file name is the same in both, and `joined` still carries the
287    // original link if the final component is itself a symlink.
288    if let Ok(meta) = std::fs::symlink_metadata(&joined)
289        && meta.file_type().is_symlink()
290    {
291        return Err(PathSafetyError::SymlinkTarget {
292            path: canonical_path,
293        });
294    }
295    // Also check via canonical_path in case it differs from joined.
296    if let Ok(meta) = std::fs::symlink_metadata(&canonical_path)
297        && meta.file_type().is_symlink()
298    {
299        return Err(PathSafetyError::SymlinkTarget {
300            path: canonical_path,
301        });
302    }
303
304    Ok(canonical_path)
305}
306
307// ─────────────────────────────────────────────────────────────────────────────
308// Tests
309// ─────────────────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use std::fs;
314
315    use tempfile::TempDir;
316
317    use super::*;
318
319    fn tmp_workspace() -> TempDir {
320        TempDir::new().expect("TempDir::new failed")
321    }
322
323    // ── Happy path: relative path inside workspace ────────────────────────────
324
325    /// A relative path such as `.sqry/graph/derived.sqry` joined against the
326    /// workspace root must canonicalize correctly and return
327    /// `Ok(canonical_path)` once the parent directory exists.
328    #[test]
329    fn happy_path_relative_under_workspace() {
330        let ws = tmp_workspace();
331
332        // Parent directory must exist for canonicalize to succeed.
333        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
334
335        let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
336
337        assert!(result.is_ok(), "happy path should succeed; got {result:?}");
338        let canonical = result.unwrap();
339
340        // Must be inside the workspace.
341        assert!(
342            canonical.starts_with(ws.path().canonicalize().unwrap()),
343            "canonical path must be inside workspace: {canonical:?}"
344        );
345        // Must retain the file name.
346        assert!(
347            canonical.ends_with("derived.sqry"),
348            "canonical path must end with 'derived.sqry': {canonical:?}"
349        );
350    }
351
352    // ── Happy path: non-existing target file is not an error ─────────────────
353
354    /// `validate_path_in_workspace` must succeed even when the target file
355    /// does not yet exist. This is the normal "first save" scenario.
356    #[test]
357    fn happy_path_nonexistent_target_is_ok() {
358        let ws = tmp_workspace();
359        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
360
361        // `derived.sqry` does NOT exist yet.
362        let target = Path::new(".sqry/graph/derived.sqry");
363        assert!(
364            !ws.path().join(target).exists(),
365            "pre-condition: target must not exist"
366        );
367
368        let result = validate_path_in_workspace(target, ws.path());
369        assert!(
370            result.is_ok(),
371            "non-existent target should be allowed; got {result:?}"
372        );
373    }
374
375    // ── Happy path: absolute path inside workspace ────────────────────────────
376
377    /// An absolute path that is genuinely inside the workspace must succeed.
378    #[test]
379    fn happy_path_absolute_under_workspace() {
380        let ws = tmp_workspace();
381        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
382
383        // Absolute path constructed from the workspace root.
384        let abs = ws.path().join(".sqry/graph/derived.sqry");
385        assert!(abs.is_absolute(), "pre-condition: abs must be absolute");
386
387        let result = validate_path_in_workspace(&abs, ws.path());
388        assert!(
389            result.is_ok(),
390            "absolute in-workspace path should succeed; got {result:?}"
391        );
392    }
393
394    // ── Happy path: already-existing regular file is fine ────────────────────
395
396    /// An existing regular file (not a symlink) must be accepted.
397    #[test]
398    fn happy_path_existing_regular_file() {
399        let ws = tmp_workspace();
400        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
401        let target = ws.path().join(".sqry/graph/derived.sqry");
402        fs::write(&target, b"previous data").unwrap();
403
404        let result = validate_path_in_workspace(&target, ws.path());
405        assert!(
406            result.is_ok(),
407            "existing regular file should succeed; got {result:?}"
408        );
409    }
410
411    // ── Rejection: path outside workspace ────────────────────────────────────
412
413    /// An absolute path that lives in a completely different directory must be
414    /// rejected with `PathSafetyError::OutsideWorkspace`.
415    #[test]
416    fn rejects_path_outside_workspace() {
417        let ws = tmp_workspace();
418        let outside = TempDir::new().unwrap();
419
420        // Pre-create the parent inside the outside dir so the parent
421        // canonicalization step succeeds — the rejection must come from the
422        // descendant check, not from a missing parent.
423        let result = validate_path_in_workspace(&outside.path().join("derived.sqry"), ws.path());
424
425        match result {
426            Err(PathSafetyError::OutsideWorkspace { .. }) => {}
427            other => panic!("expected OutsideWorkspace, got {other:?}"),
428        }
429    }
430
431    /// A relative path that escapes via `../..` must be rejected as
432    /// `OutsideWorkspace` after joining and canonicalization.
433    #[test]
434    fn rejects_dotdot_escape() {
435        let ws = tmp_workspace();
436        // The parent of the temp dir is guaranteed to exist.
437        let result = validate_path_in_workspace(Path::new("../../etc/passwd"), ws.path());
438
439        match result {
440            Err(PathSafetyError::OutsideWorkspace { .. }) => {}
441            // If `../../etc` doesn't exist the canonicalize of the parent
442            // returns an Io error, which is also an acceptable rejection.
443            Err(PathSafetyError::Io(_)) => {}
444            other => panic!("expected OutsideWorkspace or Io, got {other:?}"),
445        }
446    }
447
448    // ── Rejection: symlink target ─────────────────────────────────────────────
449
450    /// When the target path itself is a symlink, `SymlinkTarget` must be
451    /// returned. This test only runs on Unix (Windows symlinks need elevation).
452    #[cfg(unix)]
453    #[test]
454    fn rejects_symlink_target() {
455        let ws = tmp_workspace();
456        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
457
458        // Create a real file and a symlink pointing to it, both inside the
459        // workspace. The symlink is the path we pass as the target.
460        let real = ws.path().join(".sqry/graph/real.sqry");
461        fs::write(&real, b"x").unwrap();
462        let link = ws.path().join(".sqry/graph/derived.sqry");
463        std::os::unix::fs::symlink(&real, &link).unwrap();
464
465        let result = validate_path_in_workspace(&link, ws.path());
466
467        match result {
468            Err(PathSafetyError::SymlinkTarget { .. }) => {}
469            other => panic!("expected SymlinkTarget, got {other:?}"),
470        }
471    }
472
473    /// Symlink target rejection also fires for dangling symlinks (symlinks
474    /// whose destination does not exist).
475    #[cfg(unix)]
476    #[test]
477    fn rejects_dangling_symlink_target() {
478        let ws = tmp_workspace();
479        fs::create_dir_all(ws.path().join(".sqry/graph")).unwrap();
480
481        let link = ws.path().join(".sqry/graph/derived.sqry");
482        // Point at a non-existing destination.
483        std::os::unix::fs::symlink(ws.path().join("nonexistent"), &link).unwrap();
484
485        // The symlink itself exists even though its target does not.
486        assert!(
487            link.symlink_metadata()
488                .map(|m| m.file_type().is_symlink())
489                .unwrap_or(false),
490            "pre-condition: link must be a symlink"
491        );
492
493        let result = validate_path_in_workspace(&link, ws.path());
494
495        match result {
496            Err(PathSafetyError::SymlinkTarget { .. }) => {}
497            other => panic!("expected SymlinkTarget for dangling symlink, got {other:?}"),
498        }
499    }
500
501    // ── Rejection: symlink in ancestor ───────────────────────────────────────
502
503    /// When an ancestor directory is a symlink the call must return
504    /// `PathSafetyError::SymlinkInAncestor`.
505    ///
506    /// Setup: `.sqry` is a symlink → `.sqry_real` (a real directory).
507    /// The path `.sqry/graph/derived.sqry` passes through the symlinked
508    /// ancestor `.sqry`.
509    #[cfg(unix)]
510    #[test]
511    fn rejects_symlink_in_ancestor() {
512        let ws = tmp_workspace();
513
514        // Create real backing directory tree.
515        fs::create_dir_all(ws.path().join(".sqry_real/graph")).unwrap();
516
517        // Make `.sqry` a symlink pointing to `.sqry_real`.
518        std::os::unix::fs::symlink(ws.path().join(".sqry_real"), ws.path().join(".sqry")).unwrap();
519
520        // The relative path traverses the symlinked ancestor `.sqry`.
521        let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
522
523        match result {
524            Err(PathSafetyError::SymlinkInAncestor { .. }) => {}
525            other => panic!("expected SymlinkInAncestor, got {other:?}"),
526        }
527    }
528
529    /// Intermediate-level symlink: only `graph` is a symlink inside a real
530    /// `.sqry` directory. The ancestor walk must still detect it.
531    #[cfg(unix)]
532    #[test]
533    fn rejects_symlink_intermediate_ancestor() {
534        let ws = tmp_workspace();
535
536        // Real tree: `.sqry/` (real dir) → `graph_real/` (real dir).
537        fs::create_dir_all(ws.path().join(".sqry")).unwrap();
538        fs::create_dir_all(ws.path().join("graph_real")).unwrap();
539
540        // `.sqry/graph` → `../../graph_real` (relative symlink into workspace).
541        std::os::unix::fs::symlink(ws.path().join("graph_real"), ws.path().join(".sqry/graph"))
542            .unwrap();
543
544        let result = validate_path_in_workspace(Path::new(".sqry/graph/derived.sqry"), ws.path());
545
546        match result {
547            Err(PathSafetyError::SymlinkInAncestor { .. }) => {}
548            other => panic!("expected SymlinkInAncestor for intermediate symlink, got {other:?}"),
549        }
550    }
551
552    // ── Error quality: Display messages cite paths ────────────────────────────
553
554    /// `Display` for each variant must produce a non-empty message that
555    /// contains the offending path string.
556    #[test]
557    fn display_outside_workspace_cites_paths() {
558        let err = PathSafetyError::OutsideWorkspace {
559            path: PathBuf::from("/tmp/escape/foo.sqry"),
560            workspace_root: PathBuf::from("/home/user/project"),
561        };
562        let msg = err.to_string();
563        assert!(
564            msg.contains("/tmp/escape/foo.sqry"),
565            "Display must cite the offending path; got: {msg}"
566        );
567        assert!(
568            msg.contains("/home/user/project"),
569            "Display must cite the workspace root; got: {msg}"
570        );
571    }
572
573    #[test]
574    fn display_symlink_target_cites_path() {
575        let err = PathSafetyError::SymlinkTarget {
576            path: PathBuf::from("/ws/.sqry/graph/derived.sqry"),
577        };
578        let msg = err.to_string();
579        assert!(
580            msg.contains("/ws/.sqry/graph/derived.sqry"),
581            "Display must cite the symlink path; got: {msg}"
582        );
583    }
584
585    #[test]
586    fn display_symlink_in_ancestor_cites_path() {
587        let err = PathSafetyError::SymlinkInAncestor {
588            ancestor: PathBuf::from("/ws/.sqry/graph"),
589        };
590        let msg = err.to_string();
591        assert!(
592            msg.contains("/ws/.sqry/graph"),
593            "Display must cite the ancestor path; got: {msg}"
594        );
595    }
596
597    #[test]
598    fn display_io_delegates_to_io_error() {
599        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
600        let err = PathSafetyError::Io(io_err);
601        let msg = err.to_string();
602        assert!(!msg.is_empty(), "Display for Io variant must not be empty");
603    }
604
605    // ── Error trait: source() for Io variant ─────────────────────────────────
606
607    #[test]
608    fn error_source_io_variant_is_some() {
609        use std::error::Error as _;
610        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
611        let err = PathSafetyError::Io(io_err);
612        assert!(
613            err.source().is_some(),
614            "Io variant must expose source via std::error::Error::source()"
615        );
616    }
617
618    #[test]
619    fn error_source_non_io_variants_are_none() {
620        use std::error::Error as _;
621
622        let outside = PathSafetyError::OutsideWorkspace {
623            path: PathBuf::from("/a"),
624            workspace_root: PathBuf::from("/b"),
625        };
626        assert!(outside.source().is_none());
627
628        let sym_target = PathSafetyError::SymlinkTarget {
629            path: PathBuf::from("/a"),
630        };
631        assert!(sym_target.source().is_none());
632
633        let sym_anc = PathSafetyError::SymlinkInAncestor {
634            ancestor: PathBuf::from("/a"),
635        };
636        assert!(sym_anc.source().is_none());
637    }
638
639    // ── From<io::Error> ───────────────────────────────────────────────────────
640
641    #[test]
642    fn from_io_error_constructs_io_variant() {
643        let io_err = std::io::Error::other("test");
644        let safety_err = PathSafetyError::from(io_err);
645        assert!(
646            matches!(safety_err, PathSafetyError::Io(_)),
647            "From<io::Error> must yield the Io variant"
648        );
649    }
650}