Skip to main content

localharness/builtins/
rename_file.rs

1//! `rename_file` — rename or move a file / directory.
2//!
3//! Wraps [`Filesystem::rename`]. Native backend uses an atomic
4//! `std::fs::rename`; OPFS falls back to read + write + delete via
5//! the default trait impl.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::filesystem::{EntryKind, SharedFilesystem};
14use crate::tools::{Tool, ToolContext};
15
16pub struct RenameFile {
17    fs: SharedFilesystem,
18}
19
20impl RenameFile {
21    pub fn new(fs: SharedFilesystem) -> Self {
22        Self { fs }
23    }
24}
25
26crate::tool_params! {
27    /// ONE table generates both this struct and `input_schema` (see
28    /// `crate::tool_params`); the schema byte-identity test is below.
29    struct Args: serde {
30        from: req_str = "Current path.",
31        to: req_str = "New path.",
32    }
33}
34
35#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
36#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
37impl Tool for RenameFile {
38    fn name(&self) -> &str {
39        "rename_file"
40    }
41
42    fn description(&self) -> &str {
43        "Rename or move a file from `from` to `to`. On native, atomic \
44         when both paths are on the same filesystem. On OPFS, performs \
45         read + write + delete (not atomic but safe — original is only \
46         removed after the new path lands)."
47    }
48
49    fn input_schema(&self) -> Value {
50        Args::schema()
51    }
52
53    async fn execute(&self, args: Value, _ctx: Option<Arc<ToolContext>>) -> Result<Value> {
54        let args: Args = serde_json::from_value(args)
55            .map_err(|e| Error::bad_args("rename_file", format!("rename_file args: {e}")))?;
56        if args.from == args.to {
57            return Err(Error::bad_args("rename_file", "from and to are identical"));
58        }
59        // Renaming the seed/device key away (or clobbering one) bricks identity.
60        if crate::builtins::is_protected_path(&args.from) {
61            return Err(crate::builtins::protected_path_error(&args.from));
62        }
63        if crate::builtins::is_protected_path(&args.to) {
64            return Err(crate::builtins::protected_path_error(&args.to));
65        }
66        // Renaming a DIRECTORY relocates everything under it, so renaming an
67        // ancestor of the seed moves `.lh_wallet` even though the from/to
68        // basename checks above pass — the same identity brick. Walk `from`
69        // when it's a directory and refuse if it CONTAINS a protected file.
70        // (Best-effort, like the existence check below.) (I8)
71        if matches!(self.fs.metadata(&args.from).await, Ok(Some(m)) if m.kind == EntryKind::Directory) {
72            if let Ok(entries) = self.fs.walk(&args.from, None).await {
73                if let Some(hit) = entries
74                    .iter()
75                    .find(|e| crate::builtins::is_protected_path(&e.path))
76                {
77                    return Err(crate::builtins::protected_path_error(&hit.path));
78                }
79            }
80        }
81        // Refuse to SILENTLY clobber an existing destination — native rename
82        // overwrites by platform default, which is irreversible data loss and
83        // rename_file is not confirm-gated. Best-effort existence check (a
84        // transient metadata error falls through to the rename, as before); the
85        // caller deletes `to` first to intentionally overwrite.
86        if matches!(self.fs.metadata(&args.to).await, Ok(Some(_))) {
87            return Err(Error::other(format!(
88                "destination '{}' already exists — delete it first to overwrite",
89                args.to
90            )));
91        }
92        self.fs.rename(&args.from, &args.to).await?;
93        Ok(json!({ "ok": true, "from": args.from, "to": args.to }))
94    }
95}
96
97#[cfg(test)]
98mod schema_tests {
99    use super::Args;
100    use serde_json::json;
101
102    /// BYTE-IDENTITY: the macro-generated schema must serialize byte-for-byte
103    /// equal to the hand-written literal it replaced (frozen verbatim here) —
104    /// the wire shape is model-behavior-load-bearing.
105    #[test]
106    fn schema_is_byte_identical_to_the_frozen_original() {
107        let frozen = json!({
108            "type": "object",
109            "properties": {
110                "from": { "type": "string", "description": "Current path." },
111                "to":   { "type": "string", "description": "New path." }
112            },
113            "required": ["from", "to"]
114        });
115        assert_eq!(Args::schema().to_string(), frozen.to_string());
116    }
117}
118
119#[cfg(all(test, feature = "native"))]
120mod tests {
121    use super::*;
122    use crate::filesystem::NativeFilesystem;
123
124    #[tokio::test]
125    async fn renames_a_file() {
126        let dir = std::env::temp_dir();
127        let from = dir.join(format!("rename_from_{}.txt", uuid::Uuid::new_v4()));
128        let to = dir.join(format!("rename_to_{}.txt", uuid::Uuid::new_v4()));
129        std::fs::write(&from, "hello").unwrap();
130        let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
131        let out = tool
132            .execute(
133                json!({"from": from.display().to_string(), "to": to.display().to_string()}),
134                None,
135            )
136            .await
137            .unwrap();
138        assert_eq!(out["ok"], json!(true));
139        assert!(!from.exists());
140        assert_eq!(std::fs::read_to_string(&to).unwrap(), "hello");
141        let _ = std::fs::remove_file(to);
142    }
143
144    #[tokio::test]
145    async fn rejects_identical_paths() {
146        let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
147        let res = tool
148            .execute(json!({"from": "x.txt", "to": "x.txt"}), None)
149            .await;
150        assert!(res.is_err());
151    }
152
153    #[tokio::test]
154    async fn refuses_to_clobber_existing_destination() {
155        // Native rename overwrites by platform default — silent, irreversible
156        // data loss, and rename_file isn't confirm-gated. It must refuse instead.
157        let dir = std::env::temp_dir();
158        let from = dir.join(format!("rn_from_{}.txt", uuid::Uuid::new_v4()));
159        let to = dir.join(format!("rn_to_{}.txt", uuid::Uuid::new_v4()));
160        std::fs::write(&from, "SOURCE").unwrap();
161        std::fs::write(&to, "IMPORTANT").unwrap();
162        let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
163        let res = tool
164            .execute(
165                json!({"from": from.display().to_string(), "to": to.display().to_string()}),
166                None,
167            )
168            .await;
169        assert!(res.is_err(), "must refuse to clobber an existing destination");
170        assert_eq!(std::fs::read_to_string(&to).unwrap(), "IMPORTANT", "dest untouched");
171        assert_eq!(std::fs::read_to_string(&from).unwrap(), "SOURCE", "source untouched");
172        let _ = std::fs::remove_file(from);
173        let _ = std::fs::remove_file(to);
174    }
175
176    /// Renaming a DIRECTORY that contains a protected identity file relocates
177    /// the seed — the from/to basename guards don't see nested contents, so it
178    /// must be refused. (I8)
179    #[tokio::test]
180    async fn refuses_to_rename_a_dir_containing_the_seed() {
181        let base = std::env::temp_dir().join(format!("rn_dir_{}", uuid::Uuid::new_v4()));
182        let from = base.join("from");
183        std::fs::create_dir_all(&from).unwrap();
184        let seed = from.join(".lh_wallet");
185        std::fs::write(&seed, b"SECRET SEED PHRASE").unwrap();
186        let to = base.join("to");
187        let tool = RenameFile::new(Arc::new(NativeFilesystem::new()));
188        let res = tool
189            .execute(
190                json!({"from": from.display().to_string(), "to": to.display().to_string()}),
191                None,
192            )
193            .await;
194        assert!(res.is_err(), "must refuse to rename a dir holding the seed");
195        assert!(seed.exists(), "seed must stay put after the refused rename");
196        assert!(!to.exists(), "destination must not be created");
197        std::fs::remove_dir_all(&base).ok();
198    }
199}