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}