1use super::{
9 default_path_input, has_windows_path_prefix, normalize_relative_path,
10 pathbuf_to_workspace_path, validate_relative_pattern, CommandOutput, CommandRequest,
11 WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceFileType,
12 WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
13 WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest,
14 WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest,
15 WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus,
16 WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider,
17 WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult,
18 WorkspacePath, WorkspacePathResolver, WorkspaceSearch, WorkspaceWriteOutcome,
19};
20use anyhow::{anyhow, bail, Result};
21use async_trait::async_trait;
22use std::path::{Component, Path, PathBuf};
23
24#[derive(Debug)]
26pub struct LocalWorkspaceBackend {
27 pub(super) root: PathBuf,
28}
29
30impl LocalWorkspaceBackend {
31 pub fn new(root: PathBuf) -> Self {
32 let canonical = root.canonicalize();
33 let root = match canonical {
34 Ok(canonical) => canonical,
35 Err(e) => {
36 tracing::warn!(
37 "LocalWorkspaceBackend: failed to canonicalize root '{}' at construction: {} \
38 (path resolution will fail-closed at first use)",
39 root.display(),
40 e
41 );
42 root
43 }
44 };
45 Self { root }
46 }
47
48 fn local_path_for_read(&self, path: &WorkspacePath) -> Result<PathBuf> {
49 a3s_common::tools::resolve_path(&self.root, path.as_str()).map_err(|e| anyhow!("{}", e))
50 }
51
52 fn local_path_for_write(&self, path: &WorkspacePath) -> Result<PathBuf> {
53 let target = if path.is_root() {
54 self.root.clone()
55 } else {
56 self.root.join(path.as_str())
57 };
58
59 if let Some(parent) = target.parent() {
60 std::fs::create_dir_all(parent).map_err(|e| {
61 anyhow!(
62 "Failed to create parent directories for {}: {}",
63 target.display(),
64 e
65 )
66 })?;
67 }
68
69 a3s_common::tools::resolve_path_for_write(&self.root, path.as_str())
70 .map_err(|e| anyhow!("{}", e))
71 }
72}
73
74impl WorkspacePathResolver for LocalWorkspaceBackend {
75 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
76 normalize_local_path(&self.root, input)
77 }
78}
79
80#[async_trait]
81impl WorkspaceFileSystem for LocalWorkspaceBackend {
82 async fn read_text(&self, path: &WorkspacePath) -> Result<String> {
83 let resolved = self.local_path_for_read(path)?;
84 tokio::fs::read_to_string(&resolved)
85 .await
86 .map_err(|e| anyhow!("Failed to read file {}: {}", resolved.display(), e))
87 }
88
89 async fn write_text(
90 &self,
91 path: &WorkspacePath,
92 content: &str,
93 ) -> Result<WorkspaceWriteOutcome> {
94 let resolved = self.local_path_for_write(path)?;
95 tokio::fs::write(&resolved, content)
96 .await
97 .map_err(|e| anyhow!("Failed to write file {}: {}", resolved.display(), e))?;
98
99 Ok(WorkspaceWriteOutcome {
100 bytes: content.len(),
101 lines: content.lines().count(),
102 })
103 }
104
105 async fn list_dir(&self, path: &WorkspacePath) -> Result<Vec<WorkspaceDirEntry>> {
106 let target = self.local_path_for_read(path)?;
107 if !target.exists() {
108 bail!("Directory not found: {}", target.display());
109 }
110 if !target.is_dir() {
111 bail!("Not a directory: {}", target.display());
112 }
113
114 let mut dir = tokio::fs::read_dir(&target)
115 .await
116 .map_err(|e| anyhow!("Failed to read directory {}: {}", target.display(), e))?;
117 let mut entries = Vec::new();
118
119 while let Some(entry) = dir.next_entry().await? {
120 let name = entry.file_name().to_string_lossy().to_string();
121 let file_type = entry.file_type().await;
122 let metadata = entry.metadata().await;
123 let (kind, size) = match (&file_type, &metadata) {
124 (Ok(ft), Ok(m)) => {
125 let kind = if ft.is_dir() {
126 WorkspaceFileType::Directory
127 } else if ft.is_symlink() {
128 WorkspaceFileType::Symlink
129 } else {
130 WorkspaceFileType::File
131 };
132 (kind, m.len())
133 }
134 _ => (WorkspaceFileType::Unknown, 0),
135 };
136 entries.push(WorkspaceDirEntry { name, kind, size });
137 }
138
139 Ok(entries)
140 }
141}
142
143#[async_trait]
144impl WorkspaceSearch for LocalWorkspaceBackend {
145 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
146 validate_relative_pattern(&request.pattern, "glob pattern")?;
147 let base = self.local_path_for_read(&request.base)?;
148 let full_pattern = base.join(&request.pattern);
149 let full_pattern = full_pattern.to_string_lossy().replace('\\', "/");
150
151 let entries = glob::glob(&full_pattern)
152 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
153
154 let mut matches = Vec::new();
155 for entry in entries {
156 match entry {
157 Ok(path) => {
158 if let Ok(relative) = path.strip_prefix(&self.root) {
159 matches.push(pathbuf_to_workspace_path(relative));
160 }
161 }
162 Err(e) => tracing::warn!("Glob entry error: {}", e),
163 }
164 }
165
166 matches.sort_by(|a, b| a.as_str().cmp(b.as_str()));
167 Ok(WorkspaceGlobResult { matches })
168 }
169
170 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
171 if let Some(ref glob) = request.glob {
172 validate_relative_pattern(glob, "grep glob filter")?;
173 }
174
175 let regex_pattern = if request.case_insensitive {
176 format!("(?i){}", request.pattern)
177 } else {
178 request.pattern.clone()
179 };
180 let regex = regex::Regex::new(®ex_pattern)
181 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
182
183 let search_path = self.local_path_for_read(&request.base)?;
184 let mut builder = ignore::WalkBuilder::new(&search_path);
185 builder.hidden(false).git_ignore(true).git_global(true);
186
187 if let Some(ref glob_pat) = request.glob {
188 let mut types = ignore::types::TypesBuilder::new();
189 types.add("custom", glob_pat).ok();
190 types.select("custom");
191 if let Ok(built) = types.build() {
192 builder.types(built);
193 }
194 }
195
196 let mut output = String::new();
197 let mut match_count = 0;
198 let mut file_count = 0;
199 let mut total_size = 0;
200
201 for entry in builder.build().flatten() {
202 if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
203 continue;
204 }
205
206 let file_path = entry.path();
207 let content = match std::fs::read_to_string(file_path) {
208 Ok(content) => content,
209 Err(_) => continue,
210 };
211
212 let lines: Vec<&str> = content.lines().collect();
213 let mut file_matches = Vec::new();
214 for (line_idx, line) in lines.iter().enumerate() {
215 if regex.is_match(line) {
216 file_matches.push(line_idx);
217 }
218 }
219
220 if file_matches.is_empty() {
221 continue;
222 }
223
224 file_count += 1;
225 let rel_path = file_path
226 .strip_prefix(&self.root)
227 .unwrap_or(file_path)
228 .to_string_lossy()
229 .replace('\\', "/");
230
231 for &match_idx in &file_matches {
232 if total_size > request.max_output_size {
233 return Ok(WorkspaceGrepResult {
234 output,
235 match_count,
236 file_count,
237 truncated: true,
238 });
239 }
240
241 match_count += 1;
242
243 let start = match_idx.saturating_sub(request.context_lines);
244 let end = (match_idx + request.context_lines + 1).min(lines.len());
245
246 for (i, line) in lines[start..end].iter().enumerate() {
247 let abs_i = start + i;
248 let prefix = if abs_i == match_idx { ">" } else { " " };
249 let line = format!("{}{}:{}: {}\n", prefix, rel_path, abs_i + 1, line);
250 total_size += line.len();
251 output.push_str(&line);
252 }
253
254 if request.context_lines > 0 {
255 output.push_str("--\n");
256 total_size += 3;
257 }
258 }
259 }
260
261 Ok(WorkspaceGrepResult {
262 output,
263 match_count,
264 file_count,
265 truncated: false,
266 })
267 }
268}
269
270#[async_trait]
271impl WorkspaceGit for LocalWorkspaceBackend {
272 async fn is_repository(&self) -> Result<bool> {
273 self.run_blocking_git(|root| Ok(crate::git::is_git_repo(&root)))
274 .await
275 }
276
277 async fn status(&self) -> Result<WorkspaceGitStatus> {
278 self.run_blocking_git(|root| {
279 let status = crate::git::get_status(&root)?;
280 Ok(WorkspaceGitStatus {
281 branch: status.branch,
282 commit: status.commit,
283 is_worktree: status.is_worktree,
284 is_dirty: status.is_dirty,
285 dirty_count: status.dirty_count,
286 })
287 })
288 .await
289 }
290
291 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
292 self.run_blocking_git(move |root| {
293 Ok(crate::git::get_log(&root, max_count)?
294 .into_iter()
295 .map(|commit| WorkspaceGitCommit {
296 id: commit.id,
297 message: commit.message,
298 author: commit.author,
299 date: commit.date,
300 })
301 .collect())
302 })
303 .await
304 }
305
306 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
307 self.run_blocking_git(|root| {
308 Ok(crate::git::list_branches(&root)?
309 .into_iter()
310 .map(|branch| WorkspaceGitBranch {
311 name: branch.name,
312 is_current: branch.is_current,
313 })
314 .collect())
315 })
316 .await
317 }
318
319 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
320 self.run_blocking_git(move |root| {
321 crate::git::create_branch(&root, &request.name, &request.base)
322 })
323 .await
324 }
325
326 async fn checkout(
327 &self,
328 request: WorkspaceGitCheckoutRequest,
329 ) -> Result<WorkspaceGitCheckoutOutput> {
330 let args = if request.force {
331 vec![
332 "checkout".to_string(),
333 "--force".to_string(),
334 request.refspec,
335 ]
336 } else {
337 vec!["checkout".to_string(), request.refspec]
338 };
339 let (success, stdout, stderr) = self.run_git_command(args).await?;
340 if !success {
341 bail!("{}", stderr.trim_end());
342 }
343 Ok(WorkspaceGitCheckoutOutput { stdout })
344 }
345
346 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
347 self.run_blocking_git(move |root| crate::git::get_diff(&root, request.target.as_deref()))
348 .await
349 }
350
351 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
352 let (success, stdout, stderr) = self
353 .run_git_command(vec!["remote".to_string(), "-v".to_string()])
354 .await?;
355 if !success {
356 bail!("{}", stderr.trim_end());
357 }
358
359 Ok(stdout.lines().filter_map(parse_git_remote_line).collect())
360 }
361}
362
363#[async_trait]
364impl WorkspaceGitStashProvider for LocalWorkspaceBackend {
365 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
366 self.run_blocking_git(|root| {
367 Ok(crate::git::list_stashes(&root)?
368 .into_iter()
369 .map(|stash| WorkspaceGitStash {
370 index: stash.index,
371 message: stash.message,
372 })
373 .collect())
374 })
375 .await
376 }
377
378 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
379 self.run_blocking_git(move |root| {
380 crate::git::stash(&root, request.message.as_deref(), request.include_untracked)
381 })
382 .await
383 }
384}
385
386#[async_trait]
387impl WorkspaceGitWorktreeProvider for LocalWorkspaceBackend {
388 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
389 self.run_blocking_git(|root| {
390 Ok(crate::git::list_worktrees(&root)?
391 .into_iter()
392 .map(|worktree| WorkspaceGitWorktree {
393 path: worktree.path,
394 branch: worktree.branch,
395 is_bare: worktree.is_bare,
396 is_detached: worktree.is_detached,
397 })
398 .collect())
399 })
400 .await
401 }
402
403 async fn create_worktree(
404 &self,
405 request: WorkspaceGitCreateWorktreeRequest,
406 ) -> Result<WorkspaceGitWorktreeMutation> {
407 let branch = request.branch;
408 let path = request
409 .path
410 .map(|path| {
411 let path = PathBuf::from(path);
412 if path.is_absolute() {
413 path
414 } else {
415 self.root.join(path)
416 }
417 })
418 .unwrap_or_else(|| default_local_worktree_path(&self.root, &branch));
419 let display_path = path.display().to_string();
420 let new_branch = request.new_branch;
421 let branch_for_git = branch.clone();
422
423 self.run_blocking_git(move |root| {
424 crate::git::create_worktree(&root, &branch_for_git, &path, new_branch)
425 })
426 .await?;
427
428 Ok(WorkspaceGitWorktreeMutation {
429 path: display_path,
430 branch: Some(branch),
431 })
432 }
433
434 async fn remove_worktree(
435 &self,
436 request: WorkspaceGitRemoveWorktreeRequest,
437 ) -> Result<WorkspaceGitWorktreeMutation> {
438 let path = PathBuf::from(request.path);
439 let display_path = path.display().to_string();
440 let force = request.force;
441
442 self.run_blocking_git(move |root| crate::git::remove_worktree(&root, &path, force))
443 .await?;
444
445 Ok(WorkspaceGitWorktreeMutation {
446 path: display_path,
447 branch: None,
448 })
449 }
450}
451
452#[async_trait]
453impl WorkspaceCommandRunner for LocalWorkspaceBackend {
454 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
455 #[cfg(windows)]
456 if let Some(output) =
457 crate::tools::builtin::bash::maybe_execute_simple_windows_http_command(&request.command)
458 .await
459 {
460 let exit_code = output
461 .metadata
462 .as_ref()
463 .and_then(|m| m.get("exit_code"))
464 .and_then(|v| v.as_i64())
465 .map(|v| v as i32)
466 .unwrap_or(if output.success { 0 } else { -1 });
467 return Ok(CommandOutput {
468 output: output.content,
469 exit_code,
470 timed_out: false,
471 });
472 }
473
474 let timeout_secs = request.timeout_ms / 1000;
475 let mut child = crate::tools::builtin::bash::spawn_shell(
476 &request.command,
477 &self.root,
478 request.env.as_deref(),
479 )
480 .map_err(|e| anyhow!("Failed to spawn shell: {}", e))?;
481
482 let (output, timed_out) = crate::tools::process::read_process_output(
483 &mut child,
484 timeout_secs,
485 request.output_observer.as_deref(),
486 )
487 .await;
488
489 if timed_out {
490 return Ok(CommandOutput {
491 output,
492 exit_code: -1,
493 timed_out: true,
494 });
495 }
496
497 let status = child
498 .wait()
499 .await
500 .map_err(|e| anyhow!("Failed to wait for shell: {}", e))?;
501 let exit_code = status.code().unwrap_or(-1);
502
503 Ok(CommandOutput {
504 output,
505 exit_code,
506 timed_out: false,
507 })
508 }
509}
510
511impl LocalWorkspaceBackend {
512 async fn run_blocking_git<T, F>(&self, operation: F) -> Result<T>
513 where
514 T: Send + 'static,
515 F: FnOnce(PathBuf) -> Result<T> + Send + 'static,
516 {
517 let root = self.root.clone();
518 tokio::task::spawn_blocking(move || operation(root))
519 .await
520 .map_err(|e| anyhow!("Git worker failed: {}", e))?
521 }
522
523 async fn run_git_command(&self, args: Vec<String>) -> Result<(bool, String, String)> {
524 tokio::task::spawn_blocking(crate::git::ensure_git_installed)
525 .await
526 .map_err(|e| anyhow!("Git worker failed: {}", e))??;
527
528 let output = tokio::process::Command::new("git")
529 .arg("-C")
530 .arg(self.root.as_os_str())
531 .args(&args)
532 .output()
533 .await
534 .map_err(|e| anyhow!("Failed to execute git: {}", e))?;
535
536 Ok((
537 output.status.success(),
538 String::from_utf8_lossy(&output.stdout).to_string(),
539 String::from_utf8_lossy(&output.stderr).to_string(),
540 ))
541 }
542}
543
544fn parse_git_remote_line(line: &str) -> Option<WorkspaceGitRemote> {
545 let mut parts = line.split_whitespace();
546 let name = parts.next()?;
547 let url = parts.next()?;
548 let direction = parts
549 .next()
550 .unwrap_or_default()
551 .trim_start_matches('(')
552 .trim_end_matches(')');
553
554 Some(WorkspaceGitRemote {
555 name: name.to_string(),
556 url: url.to_string(),
557 direction: direction.to_string(),
558 })
559}
560
561fn default_local_worktree_path(root: &Path, branch: &str) -> PathBuf {
562 let repo_name = root
563 .file_name()
564 .map(|name| name.to_string_lossy().to_string())
565 .unwrap_or_else(|| "repo".to_string());
566 root.parent()
567 .unwrap_or(root)
568 .join(format!("{repo_name}-{branch}"))
569}
570
571pub(super) fn normalize_local_path(root: &Path, input: &str) -> Result<WorkspacePath> {
572 let input = default_path_input(input);
573 let candidate = Path::new(input);
574
575 if candidate.is_absolute() {
576 let root = normalize_absolute_path(root)?;
577 let target = normalize_absolute_path(candidate)?;
578 if !target.starts_with(&root) {
579 bail!(
580 "Workspace boundary violation: path '{}' escapes workspace '{}'",
581 input,
582 root.display()
583 );
584 }
585 let relative = target
586 .strip_prefix(&root)
587 .map_err(|_| anyhow!("Failed to compute workspace-relative path"))?;
588 return Ok(pathbuf_to_workspace_path(relative));
589 }
590
591 if has_windows_path_prefix(input) {
592 bail!("Absolute paths are not supported by this workspace backend");
593 }
594
595 let normalized_input = input.replace('\\', "/");
596 let path = Path::new(&normalized_input);
597 if path.is_absolute() {
598 bail!("Absolute paths are not supported by this workspace backend");
599 }
600
601 let relative = normalize_relative_path(path)?;
602 Ok(pathbuf_to_workspace_path(&relative))
603}
604
605fn normalize_absolute_path(path: &Path) -> Result<PathBuf> {
606 let lexical = normalize_absolute_path_lexical(path)?;
607 if let Ok(canonical) = lexical.canonicalize() {
608 return Ok(canonical);
609 }
610
611 let mut current = lexical.as_path();
612 let mut suffix = Vec::new();
613 while !current.exists() {
614 let Some(file_name) = current.file_name() else {
615 return Ok(lexical);
616 };
617 suffix.push(file_name.to_os_string());
618 let Some(parent) = current.parent() else {
619 return Ok(lexical);
620 };
621 current = parent;
622 }
623
624 let mut normalized = current.canonicalize().unwrap_or_else(|_| {
625 normalize_absolute_path_lexical(current).unwrap_or_else(|_| current.into())
626 });
627 for part in suffix.iter().rev() {
628 normalized.push(part);
629 }
630 Ok(normalized)
631}
632
633fn normalize_absolute_path_lexical(path: &Path) -> Result<PathBuf> {
634 let mut out = PathBuf::new();
635 for component in path.components() {
636 match component {
637 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
638 Component::RootDir => out.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
639 Component::CurDir => {}
640 Component::Normal(part) => out.push(part),
641 Component::ParentDir => {
642 if !out.pop() {
643 bail!("Invalid absolute path");
644 }
645 }
646 }
647 }
648 Ok(out)
649}
650
651#[cfg(test)]
652mod tests {
653 use super::super::WorkspaceServices;
654 use super::*;
655
656 #[tokio::test]
657 async fn local_backend_reads_writes_and_lists() {
658 let temp = tempfile::tempdir().unwrap();
659 let services = WorkspaceServices::local(temp.path());
660 let path = services.normalize_path("dir/file.txt").unwrap();
661
662 let written = services
663 .fs()
664 .write_text(&path, "hello\nworld\n")
665 .await
666 .unwrap();
667 assert_eq!(written.bytes, 12);
668 assert_eq!(written.lines, 2);
669
670 let content = services.fs().read_text(&path).await.unwrap();
671 assert_eq!(content, "hello\nworld\n");
672
673 let dir = services.normalize_path("dir").unwrap();
674 let entries = services.fs().list_dir(&dir).await.unwrap();
675 assert_eq!(entries.len(), 1);
676 assert_eq!(entries[0].name, "file.txt");
677 }
678
679 #[tokio::test]
680 async fn local_backend_searches_glob_and_grep() {
681 let temp = tempfile::tempdir().unwrap();
682 let services = WorkspaceServices::local(temp.path());
683 services
684 .fs()
685 .write_text(
686 &services.normalize_path("src/main.rs").unwrap(),
687 "fn main() {\n println!(\"hello\");\n}\n",
688 )
689 .await
690 .unwrap();
691 services
692 .fs()
693 .write_text(
694 &services.normalize_path("README.md").unwrap(),
695 "hello from docs\n",
696 )
697 .await
698 .unwrap();
699
700 let search = services.search().expect("local backend supports search");
701 let glob = search
702 .glob(WorkspaceGlobRequest {
703 base: services.normalize_path("src").unwrap(),
704 pattern: "*.rs".to_string(),
705 })
706 .await
707 .unwrap();
708 assert_eq!(glob.matches[0].as_str(), "src/main.rs");
709
710 let grep = search
711 .grep(WorkspaceGrepRequest {
712 base: WorkspacePath::root(),
713 pattern: "hello".to_string(),
714 glob: Some("**/*.rs".to_string()),
715 context_lines: 0,
716 case_insensitive: false,
717 max_output_size: 1024,
718 })
719 .await
720 .unwrap();
721 assert_eq!(grep.match_count, 1);
722 assert_eq!(grep.file_count, 1);
723 assert!(grep.output.contains("src/main.rs:2"));
724 }
725
726 #[test]
727 fn local_backend_rejects_absolute_paths_outside_workspace() {
728 let temp = tempfile::tempdir().unwrap();
729 let services = WorkspaceServices::local(temp.path());
730 let outside = temp.path().parent().unwrap().join("secret.txt");
731 let err = services
732 .normalize_path(outside.to_str().unwrap())
733 .expect_err("outside absolute path should be rejected");
734 assert!(err.to_string().contains("escapes workspace"));
735 }
736
737 #[test]
738 fn local_backend_rejects_backslash_parent_escape() {
739 let temp = tempfile::tempdir().unwrap();
740 let services = WorkspaceServices::local(temp.path());
741 let err = services
742 .normalize_path(r"..\secret.txt")
743 .expect_err("backslash parent traversal should be rejected");
744 assert!(err.to_string().contains("escapes workspace"));
745 }
746
747 #[test]
748 fn local_backend_allows_absolute_paths_inside_workspace() {
749 let temp = tempfile::tempdir().unwrap();
750 let services = WorkspaceServices::local(temp.path());
751 let absolute = temp.path().join("src/main.rs");
752 let path = services
753 .normalize_path(absolute.to_str().unwrap())
754 .expect("absolute path inside workspace should normalize");
755 assert_eq!(path.as_str(), "src/main.rs");
756 }
757}