From 68ae75129d372d0dfc6cdc7b693a71252b089bf4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 30 Sep 2026 17:30:55 +0000 Subject: [PATCH] fix: release removed list item values --- docs/CURRENT_STATUS.md | 2 + docs/HARDENING.md | 2 + src/claro.c | 2 +- tools/validate_remove_list_item_cleanup.py | 46 ++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tools/validate_remove_list_item_cleanup.py diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 470e50c..791fc7a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -42,6 +42,8 @@ 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`. +`REMOVE` also releases the removed element from its copied list before shifting later elements. Focused verification: `python3 tools/validate_remove_list_item_cleanup.py` passed with 2,000 add/remove pairs under ASan/UBSan/LSan and no reported leaks. `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, and `git diff --check` also passed. This is a narrow, runtime-verified ownership cleanup; it does not change `REMOVE` syntax or behavior. + `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. `LIST FOLDER ... AS ...` now releases its evaluated path and the temporary folder-list value after runtime storage copies the list. `python3 tools/validate_list_folder_expression_cleanup.py` reproduced 220,000 leaked bytes across 2,000 operations under ASan/UBSan/LSan before the change and passes after it with no leak report. The working-tree checks `make -s all`, `./claro test` (0 failures), `./claro doctor`, `./claro validate`, and `git diff --check` pass. This is a narrow ownership improvement, not a claim that all runtime allocations are leak-free. diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 310909d..aadbc5b 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -33,6 +33,8 @@ Focused verification builds with AddressSanitizer/UndefinedBehaviorSanitizer, re The `REMOVE` command now releases its evaluated needle and copied list/map value after updating runtime storage. Focused verification: `python3 tools/validate_remove_expression_cleanup.py` runs 2,000 discarded string needles under ASan/UBSan/LSan; it passed with no reported leaks. This slice does not yet address the separate ownership of an item removed from a populated copied list. +When `REMOVE` finds an item in its copied list, it now frees that removed item before shifting the remaining owned values left. `python3 tools/validate_remove_list_item_cleanup.py` repeatedly adds and removes strings under ASan/UBSan/LSan to check that the item ownership is released without changing the list update behavior. + The `PUT ... KEY ... VALUE ...` command now releases its evaluated map, key, and value copies after `map_put`/`rt_set` have copied the data they own. Focused verification: `python3 tools/validate_put_expression_cleanup.py` exercises 2,000 string-valued writes under ASan/UBSan/LSan and passes with no reported leaks. This covers expression-temporary ownership only; broader malformed-input sanitizer runs still report the previously documented `rt_error` diagnostic-string leaks. ## HTTP response handling diff --git a/src/claro.c b/src/claro.c index c42ef6c..876cc66 100644 --- a/src/claro.c +++ b/src/claro.c @@ -460,7 +460,7 @@ static void exec_line(Runtime *rt,Program *p,int *pcp,const char *raw){ char buf if(strcmp(up,"SORT")==0){ char *name=xstrdup(trim_inplace(t+4)); Value v=rt_get(rt,name); value_list_sort(v); rt_set(rt,name,v); free(name); return; } if(strcmp(up,"REVERSE")==0){ char *name=xstrdup(trim_inplace(t+7)); Value v=rt_get(rt,name); value_list_reverse(v); rt_set(rt,name,v); free(name); return; } if(strcmp(up,"FIND")==0){ const char *in=find_word_ci(t,"IN"), *as=find_word_ci(t,"AS"); if(in&&as){ char *needle=substr(t+4,in); char *listname=substr(in+2,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value nv=eval_expr(rt,needle), lv=eval_expr(rt,listname); int found=0,i; if(lv.type==V_LIST){ for(i=0;icount;i++) if(value_compare(nv,lv.list->items[i])==0){ found=i+1; break; } } rt_set(rt,var,v_num(found)); free(needle); free(listname); free(var); value_free(nv); value_free(lv); } return; } - if(strcmp(up,"REMOVE")==0){ const char *from=find_word_ci(t,"FROM"); if(from){ char *needle=substr(t+6,from); char *listname=xstrdup(trim_inplace((char*)from+4)); Value nv=eval_expr(rt,needle), lv=rt_get(rt,listname); int i,j; if(lv.type==V_LIST){ for(i=0;icount;i++) if(value_compare(nv,lv.list->items[i])==0){ for(j=i;jcount-1;j++) lv.list->items[j]=lv.list->items[j+1]; lv.list->count--; break; } rt_set(rt,listname,lv); } free(needle); free(listname); value_free(nv); value_free(lv); } return; } + if(strcmp(up,"REMOVE")==0){ const char *from=find_word_ci(t,"FROM"); if(from){ char *needle=substr(t+6,from); char *listname=xstrdup(trim_inplace((char*)from+4)); Value nv=eval_expr(rt,needle), lv=rt_get(rt,listname); int i,j; if(lv.type==V_LIST){ for(i=0;icount;i++) if(value_compare(nv,lv.list->items[i])==0){ value_free(lv.list->items[i]); for(j=i;jcount-1;j++) lv.list->items[j]=lv.list->items[j+1]; lv.list->count--; break; } rt_set(rt,listname,lv); } free(needle); free(listname); value_free(nv); value_free(lv); } return; } if(strcmp(up,"TRY")==0){ int catchpos=-1,end=match_block(p,pc,"TRY","ENDTRY","CATCH",&catchpos); exec_range(rt,p,pc+1,catchpos>=0?catchpos:end); if(rt->error){ rt_clear_error(rt); if(catchpos>=0) exec_range(rt,p,catchpos+1,end); } *pcp=end<0?pc:end; return; } if(strcmp(up,"RAISE")==0){ char *e=expr_after_word(t,"RAISE"); Value v=eval_expr(rt,e); char *s=v_to_string(v); rt_error(rt,p->path,pc+1,"%s",s); free(s); free(e); return; } if(strcmp(up,"CREATE")==0||strcmp(up,"MOVE")==0||strcmp(up,"SHOW")==0||strcmp(up,"HIDE")==0||strcmp(up,"EXPORT")==0||strcmp(up,"LEARNED")==0||strcmp(up,"ENDIF")==0||strcmp(up,"ELSE")==0||strcmp(up,"DONE")==0||strcmp(up,"ENDTRY")==0||strcmp(up,"CATCH")==0||strcmp(up,"ENDCOMMENT")==0||strcmp(up,"END")==0||strcmp(up,"WAIT")==0){ return; } diff --git a/tools/validate_remove_list_item_cleanup.py b/tools/validate_remove_list_item_cleanup.py new file mode 100644 index 0000000..9c0dda0 --- /dev/null +++ b/tools/validate_remove_list_item_cleanup.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Check that REMOVE frees the removed item from its copied list.""" +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-remove-item-cleanup-") as tmp: + tmp_path = Path(tmp) + binary = tmp_path / "claro-lsan" + script = tmp_path / "remove_items.claro" + script.write_text( + 'ADD "transient item" TO names\nREMOVE "transient item" FROM names\n' * 2000, + 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=ROOT, 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: removed list items still leak") + print(run.stdout, end="") + print(run.stderr, end="") + return 1 + print("PASS: REMOVE releases removed list items") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())