From 0b22ebc58bed064b7244bc2c20b22c2602e7ceb1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 27 Sep 2026 12:29:21 +0000 Subject: [PATCH] fix: release COPY and MOVE path temporaries --- docs/CURRENT_STATUS.md | 2 +- docs/HARDENING.md | 4 ++ src/claro.c | 4 +- .../validate_copy_move_expression_cleanup.py | 48 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tools/validate_copy_move_expression_cleanup.py diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ae2ec3a..587b9c3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -42,7 +42,7 @@ The `RUN COMMAND` path now decodes POSIX `pclose()` wait status before storing ` `RUN COMMAND` is documented and validated as a trusted-code capability: it executes shell commands with the user's permissions and is not a sandbox. Claro does not claim untrusted-script safety or use a fragile blacklist sanitizer. Focused documentation coverage is `tools/validate_trusted_command_docs.py`. -`GET ... KEY ... AS ...` now releases its temporary map and key values after lookup. `python3 tools/validate_get_key_expression_cleanup.py` reproduced 1,004,000 leaked bytes before the change and passes after it with ASan/UBSan/LSan enabled. `EXISTS FILE ... AS ...` now releases its evaluated path value after checking it; `python3 tools/validate_exists_expression_cleanup.py` first reproduced 62,000 leaked bytes across 2,000 calls, then passed with ASan/UBSan/LSan enabled. These are narrow expression-temporary cleanup slices, not a claim that all runtime allocations are leak-free. Verified 2026-09-26: `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, the focused cleanup validator, and `git diff --check` pass. +`GET ... KEY ... AS ...` now releases its temporary map and key values after lookup. `python3 tools/validate_get_key_expression_cleanup.py` reproduced 1,004,000 leaked bytes before the change and passes after it with ASan/UBSan/LSan enabled. `EXISTS FILE ... AS ...` now releases its evaluated path value after checking it; `python3 tools/validate_exists_expression_cleanup.py` first reproduced 62,000 leaked bytes across 2,000 calls, then passed with ASan/UBSan/LSan enabled. These are narrow expression-temporary cleanup slices, not a claim that all runtime allocations are leak-free. The `COPY FILE` and `MOVE FILE` commands now also release their evaluated source/destination path values after conversion to strings; `python3 tools/validate_copy_move_expression_cleanup.py` reproduced 74,000 bytes leaked across 1,000 copy/move pairs before the fix and is the focused ASan/UBSan/LSan regression gate. This check does not cover every runtime allocation. Verified 2026-09-26: `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, the focused cleanup validator, and `git diff --check` pass. ## Feature matrix diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 6df0bed..310909d 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -92,3 +92,7 @@ The validator runs 2,000 prompt/input operations with AddressSanitizer, Undefine ## GET KEY temporary-value cleanup `GET ... KEY ... AS ...` releases its evaluated map copy and key value after storing the selected value. `python3 tools/validate_get_key_expression_cleanup.py` runs 2,000 lookups under ASan/UBSan/LSan; before the cleanup it reproduced 1,004,000 bytes leaked, and after it passes with the expected output and no reported leaks. + +## COPY/MOVE expression temporary cleanup + +`COPY FILE ... TO ...` and `MOVE FILE ... TO ...` release both evaluated path values after converting them to owned path strings. `python3 tools/validate_copy_move_expression_cleanup.py` runs 1,000 copy/move pairs under ASan/UBSan/LSan; before the cleanup it reproduced 74,000 bytes leaked across 4,000 path-expression values, and after it passes with no reported leaks. diff --git a/src/claro.c b/src/claro.c index e143779..41ff9b4 100644 --- a/src/claro.c +++ b/src/claro.c @@ -444,8 +444,8 @@ static void exec_line(Runtime *rt,Program *p,int *pcp,const char *raw){ char buf if(strcmp(up,"READ")==0){ const char *pcur=t+4; const char *as=find_word_ci(pcur,"AS"); if(as&&starts_ci(pcur," FILE")){ char *pe=substr(pcur+5,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); FILE *f=fopen(path,"rb"); if(!f) rt_error(rt,p->path,pc+1,"Could not read file: %s",strerror(errno)); else { Str b; int c; str_init(&b); while((c=fgetc(f))!=EOF) str_ch(&b,(char)c); fclose(f); rt_set(rt,var,v_str(b.s?b.s:"")); free(b.s);} free(path); free(pe); free(var); } return; } if(strcmp(up,"EXISTS")==0){ const char *pcur=t+6; const char *as=find_word_ci(pcur,"AS"); if(as&&starts_ci(pcur," FILE")){ char *pe=substr(pcur+5,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); rt_set(rt,var,v_bool(access(path,F_OK)==0)); free(path); value_free(pv); free(pe); free(var);} return; } if(strcmp(up,"CREATE")==0){ const char *pcur=t+6; if(starts_ci(pcur," FOLDER")){ char *pe=xstrdup(pcur+7); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); if(!make_folder(path)) rt_error(rt,p->path,pc+1,"Could not create folder: %s",strerror(errno)); free(path); free(pe); } return; } - if(strcmp(up,"COPY")==0){ const char *to=find_word_ci(t,"TO"); if(starts_ci(t+4," FILE")&&to){ char *se=substr(t+9,to); char *de=xstrdup(to+2); Value sv=eval_expr(rt,se), dv=eval_expr(rt,de); char *src=v_to_string(sv), *dst=v_to_string(dv); if(!copy_file_bytes(src,dst)) rt_error(rt,p->path,pc+1,"Could not copy file"); free(src); free(dst); free(se); free(de); } return; } - if(strcmp(up,"MOVE")==0){ const char *to=find_word_ci(t,"TO"); if(starts_ci(t+4," FILE")&&to){ char *se=substr(t+9,to); char *de=xstrdup(to+2); Value sv=eval_expr(rt,se), dv=eval_expr(rt,de); char *src=v_to_string(sv), *dst=v_to_string(dv); if(rename(src,dst)!=0) rt_error(rt,p->path,pc+1,"Could not move file: %s",strerror(errno)); free(src); free(dst); free(se); free(de); } return; } + if(strcmp(up,"COPY")==0){ const char *to=find_word_ci(t,"TO"); if(starts_ci(t+4," FILE")&&to){ char *se=substr(t+9,to); char *de=xstrdup(to+2); Value sv=eval_expr(rt,se), dv=eval_expr(rt,de); char *src=v_to_string(sv), *dst=v_to_string(dv); if(!copy_file_bytes(src,dst)) rt_error(rt,p->path,pc+1,"Could not copy file"); free(src); free(dst); free(se); free(de); value_free(sv); value_free(dv); } return; } + if(strcmp(up,"MOVE")==0){ const char *to=find_word_ci(t,"TO"); if(starts_ci(t+4," FILE")&&to){ char *se=substr(t+9,to); char *de=xstrdup(to+2); Value sv=eval_expr(rt,se), dv=eval_expr(rt,de); char *src=v_to_string(sv), *dst=v_to_string(dv); if(rename(src,dst)!=0) rt_error(rt,p->path,pc+1,"Could not move file: %s",strerror(errno)); free(src); free(dst); free(se); free(de); value_free(sv); value_free(dv); } return; } if(strcmp(up,"LIST")==0){ const char *as=find_word_ci(t,"AS"); if(starts_ci(t+4," FOLDER")&&as){ char *pe=substr(t+11,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); rt_set(rt,var,list_folder_value(path)); free(path); free(pe); free(var); } return; } if(strcmp(up,"DELETE")==0){ const char *pcur=t+6; if(starts_ci(pcur," FILE")){ char *pe=xstrdup(pcur+5); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); remove(path); free(path); free(pe);} else if(starts_ci(pcur," FOLDER")){ char *pe=xstrdup(pcur+7); Value pv=eval_expr(rt,pe); char *path=v_to_string(pv); remove_folder(path); free(path); free(pe);} return; } if(strcmp(up,"PARSE")==0){ const char *as=find_word_ci(t,"AS"); if(as){ char *ex=substr(t+10,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value s=eval_expr(rt,ex); char *txt=v_to_string(s); rt_set(rt,var,parse_json_text(txt)); free(txt); free(ex); free(var);} return; } diff --git a/tools/validate_copy_move_expression_cleanup.py b/tools/validate_copy_move_expression_cleanup.py new file mode 100644 index 0000000..b716726 --- /dev/null +++ b/tools/validate_copy_move_expression_cleanup.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Regression check for COPY/MOVE FILE expression temporary ownership.""" +from pathlib import Path +import os +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + if os.name == "nt": + print("SKIP: LeakSanitizer check is POSIX-only") + return 0 + with tempfile.TemporaryDirectory(prefix="claro-copy-move-cleanup-") as tmp: + tmp_path = Path(tmp) + binary = tmp_path / "claro-lsan" + script = tmp_path / "copy_move_expressions.claro" + (tmp_path / "copy-source.txt").write_text("sample", encoding="utf-8") + script.write_text( + ('COPY FILE "copy-source.txt" TO "copy-destination.txt"\n' + 'MOVE FILE "copy-destination.txt" TO "copy-source.txt"\n') * 1000, + encoding="utf-8", + ) + build = subprocess.run( + ["gcc", "-std=c99", "-O0", "-g", "-fsanitize=address,undefined", + "src/claro.c", "-o", str(binary), "-lm"], + cwd=ROOT, text=True, capture_output=True, + ) + if build.returncode: + print(build.stdout, end="") + print(build.stderr, end="") + return build.returncode + run = subprocess.run( + [str(binary), str(script)], cwd=tmp_path, text=True, capture_output=True, + env={**os.environ, "ASAN_OPTIONS": "detect_leaks=1"}, + ) + if run.returncode or "LeakSanitizer" in run.stderr or "SUMMARY:" in run.stderr: + print("FAIL: COPY/MOVE FILE expression values still leak") + print(run.stdout, end="") + print(run.stderr, end="") + return 1 + print("PASS: COPY/MOVE FILE expression values are released") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())