diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6bb19cd..0234a87 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -28,7 +28,7 @@ Verified in this checkout on 2026-09-23: - `./claro doctor`: all checks `OK`. - `./claro validate`: validation passed. -The memory cleanup slices release the previous deep value when a runtime variable or map entry is overwritten, release temporary split argument arrays and strings from `DO`, `CALL`, `TEXT ... CONTAINS`, and `RANDOM`, release loaded program paths, lines, and pointer arrays at the end of each script run, and now release the remaining runtime-owned variables, functions, modules, classes, import paths, captured output, return value, and error strings before the interpreter exits. Focused coverage is `tools/validate_memory_cleanup.py`; it reports `PASS: expression token allocations are released` under an ASan/UBSan build with LeakSanitizer checking and rejects any remaining `load_program` report while exercising repeated four-argument calls plus string/list/map overwrites. Remaining memory-growth areas include other expression temporaries. The `RUN COMMAND` path remains trusted shell execution, not a sandbox. +The memory cleanup slices release the previous deep value when a runtime variable or map entry is overwritten, release temporary split argument arrays and strings from `DO`, `CALL`, `TEXT ... CONTAINS`, and `RANDOM`, release loaded program paths, lines, and pointer arrays at the end of each script run, and now release the remaining runtime-owned variables, functions, modules, classes, import paths, captured output, return value, and error strings before the interpreter exits. The expression evaluator now releases discarded intermediate `Value` operands and command boundaries release evaluated `SET`/`SAY` values after copying or printing them. Focused coverage is `tools/validate_memory_cleanup.py` plus `tools/validate_expression_cleanup.py`; the latter reports `PASS: discarded expression values are released` under an ASan/UBSan build with LeakSanitizer checking. Remaining memory-growth areas include other expression temporaries and command boundaries. The `RUN COMMAND` path remains trusted shell execution, not a sandbox. The `RUN COMMAND` path now decodes POSIX `pclose()` wait status before storing `LASTEXIT`, so a child that exits with code 3 exposes `3` rather than the encoded status `768`. Focused coverage is `tests/42_last_exit_code.claro`. HTTP responses now have a 1,048,576-byte cap and marker-like response bodies are preserved while extracting the final HTTP status marker. Focused coverage is `tools/validate_http_hardening.py`. Remaining memory-growth areas include other expression temporaries. Claro remains a trusted-script interpreter, not a sandbox. diff --git a/src/claro.c b/src/claro.c index f7e4836..cb45ce7 100644 --- a/src/claro.c +++ b/src/claro.c @@ -175,12 +175,12 @@ static Value parse_primary(Runtime *rt,Tokens *ts){ char *t=tok_next(ts); Value if(isalpha((unsigned char)t[0]) || strchr(t,'.')){ rt_error(rt,"",0,"I do not know the variable \"%s\" yet.",t); return v_none(); } return v_str(t); } -static Value parse_unary(Runtime *rt,Tokens *ts){ if(tok_match(ts,"NOT")){ Value v=parse_unary(rt,ts); return v_bool(!v_truth(v)); } if(tok_match(ts,"-")){ Value v=parse_unary(rt,ts); return v_num(-v_number(v)); } return parse_primary(rt,ts); } -static Value parse_mul(Runtime *rt,Tokens *ts){ Value v=parse_unary(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); if(strcmp(op,"*")&&strcmp(op,"/")) break; tok_next(ts); Value r=parse_unary(rt,ts); if(strcmp(op,"*")==0) v=v_num(v_number(v)*v_number(r)); else v=v_num(v_number(r)==0?0:v_number(v)/v_number(r)); } return v; } -static Value parse_add(Runtime *rt,Tokens *ts){ Value v=parse_mul(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); if(strcmp(op,"+")&&strcmp(op,"-")) break; tok_next(ts); Value r=parse_mul(rt,ts); if(strcmp(op,"+")==0){ double x,y; int vn=0,rn=0; if(v.type==V_NUM||v.type==V_BOOL){ x=v_number(v); vn=1; } else if(v.type==V_STR && parse_number_strict(v.str,&x)) vn=1; if(r.type==V_NUM||r.type==V_BOOL){ y=v_number(r); rn=1; } else if(r.type==V_STR && parse_number_strict(r.str,&y)) rn=1; if(vn&&rn) v=v_num(x+y); else if(v.type==V_STR||r.type==V_STR){ char *a=v_to_string(v), *b=v_to_string(r); Str s; str_init(&s); str_add(&s,a); str_add(&s,b); v=v_str(s.s?s.s:""); free(a); free(b); free(s.s); } else v=v_num(v_number(v)+v_number(r)); } else v=v_num(v_number(v)-v_number(r)); } return v; } -static Value parse_cmp(Runtime *rt,Tokens *ts){ Value v=parse_add(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); int c; if(!(ci_eq(op,"IS")||!strcmp(op,"=")||!strcmp(op,"!=")||!strcmp(op,"<")||!strcmp(op,"<=")||!strcmp(op,">")||!strcmp(op,">="))) break; tok_next(ts); Value r=parse_add(rt,ts); c=value_compare(v,r); if(ci_eq(op,"IS")||!strcmp(op,"=")) v=v_bool(c==0); else if(!strcmp(op,"!=")) v=v_bool(c!=0); else if(!strcmp(op,"<")) v=v_bool(c<0); else if(!strcmp(op,"<=")) v=v_bool(c<=0); else if(!strcmp(op,">")) v=v_bool(c>0); else v=v_bool(c>=0); } return v; } -static Value parse_and(Runtime *rt,Tokens *ts){ Value v=parse_cmp(rt,ts); while(tok_match(ts,"AND")){ Value r=parse_cmp(rt,ts); v=v_bool(v_truth(v)&&v_truth(r)); } return v; } -static Value parse_expr(Runtime *rt,Tokens *ts){ Value v=parse_and(rt,ts); while(tok_match(ts,"OR")){ Value r=parse_and(rt,ts); v=v_bool(v_truth(v)||v_truth(r)); } return v; } +static Value parse_unary(Runtime *rt,Tokens *ts){ if(tok_match(ts,"NOT")){ Value v=parse_unary(rt,ts); Value out=v_bool(!v_truth(v)); value_free(v); return out; } if(tok_match(ts,"-")){ Value v=parse_unary(rt,ts); Value out=v_num(-v_number(v)); value_free(v); return out; } return parse_primary(rt,ts); } +static Value parse_mul(Runtime *rt,Tokens *ts){ Value v=parse_unary(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); Value out; if(strcmp(op,"*")&&strcmp(op,"/")) break; tok_next(ts); { Value r=parse_unary(rt,ts); if(strcmp(op,"*")==0) out=v_num(v_number(v)*v_number(r)); else out=v_num(v_number(r)==0?0:v_number(v)/v_number(r)); value_free(v); value_free(r); v=out; } } return v; } +static Value parse_add(Runtime *rt,Tokens *ts){ Value v=parse_mul(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); Value out; if(strcmp(op,"+")&&strcmp(op,"-")) break; tok_next(ts); { Value r=parse_mul(rt,ts); if(strcmp(op,"+")==0){ double x,y; int vn=0,rn=0; if(v.type==V_NUM||v.type==V_BOOL){ x=v_number(v); vn=1; } else if(v.type==V_STR && parse_number_strict(v.str,&x)) vn=1; if(r.type==V_NUM||r.type==V_BOOL){ y=v_number(r); rn=1; } else if(r.type==V_STR && parse_number_strict(r.str,&y)) rn=1; if(vn&&rn) out=v_num(x+y); else if(v.type==V_STR||r.type==V_STR){ char *a=v_to_string(v), *b=v_to_string(r); Str s; str_init(&s); str_add(&s,a); str_add(&s,b); out=v_str(s.s?s.s:""); free(a); free(b); free(s.s); } else out=v_num(v_number(v)+v_number(r)); } else out=v_num(v_number(v)-v_number(r)); value_free(v); value_free(r); v=out; } } return v; } +static Value parse_cmp(Runtime *rt,Tokens *ts){ Value v=parse_add(rt,ts); while(!tok_end(ts)){ char *op=tok_peek(ts); int c; Value out; if(!(ci_eq(op,"IS")||!strcmp(op,"=")||!strcmp(op,"!=")||!strcmp(op,"<")||!strcmp(op,"<=")||!strcmp(op,">")||!strcmp(op,">="))) break; tok_next(ts); { Value r=parse_add(rt,ts); c=value_compare(v,r); if(ci_eq(op,"IS")||!strcmp(op,"=")) out=v_bool(c==0); else if(!strcmp(op,"!=")) out=v_bool(c!=0); else if(!strcmp(op,"<")) out=v_bool(c<0); else if(!strcmp(op,"<=")) out=v_bool(c<=0); else if(!strcmp(op,">")) out=v_bool(c>0); else out=v_bool(c>=0); value_free(v); value_free(r); v=out; } } return v; } +static Value parse_and(Runtime *rt,Tokens *ts){ Value v=parse_cmp(rt,ts); while(tok_match(ts,"AND")){ Value r=parse_cmp(rt,ts); Value out=v_bool(v_truth(v)&&v_truth(r)); value_free(v); value_free(r); v=out; } return v; } +static Value parse_expr(Runtime *rt,Tokens *ts){ Value v=parse_and(rt,ts); while(tok_match(ts,"OR")){ Value r=parse_and(rt,ts); Value out=v_bool(v_truth(v)||v_truth(r)); value_free(v); value_free(r); v=out; } return v; } static Value eval_expr(Runtime *rt,const char *expr){ Tokens ts=tokenize(expr); Value v=parse_expr(rt,&ts); tokens_free(&ts); return v; } /* Better string token parsing helpers for commands. */ @@ -392,7 +392,7 @@ static void do_import(Runtime *rt,const char *path,const char *ns,const char *cu rt->import_depth--; free(prefix); } static void create_object_value(Runtime *rt,const char *file,int line,const char *cls,const char *var){ Value obj=v_map(); ClassDef *c=find_class(rt,cls); FieldDef *fd; char fullname[256]; if(!cls||!*cls||!var||!*var){ rt_error(rt,file,line,"NEW needs a class and a name. Try: NEW Player player"); return; } map_put(obj.map,"class",v_str(cls)); rt_set_checked(rt,file,line,var,"OBJECT",obj); if(c){ for(fd=c->fields;fd;fd=fd->next){ Value def=claro_default_for_type(fd->type); snprintf(fullname,sizeof(fullname),"%s.%s",var,fd->name); rt_set_checked(rt,file,line,fullname,fd->type,def); } } } static void exec_line(Runtime *rt,Program *p,int *pcp,const char *raw){ char buf[4096],up[64],w[128]; char *t; int pc=*pcp; strncpy(buf,raw,sizeof(buf)-1); buf[sizeof(buf)-1]=0; t=trim_inplace(buf); if(is_blank_or_comment(t)) return; first_word(t,w,sizeof(w)); upper_copy(up,w,sizeof(up)); if(strcmp(up,"COMMENT")==0){ int end=match_block(p,pc,"COMMENT","ENDCOMMENT",NULL,NULL); *pcp=end<0?pc:end; return; } - if(strcmp(up,"SAY")==0){ char *e=expr_after_word(t,"SAY"); Value v=eval_expr(rt,e); if(!rt->error) out_line(rt,v); free(e); return; } + if(strcmp(up,"SAY")==0){ char *e=expr_after_word(t,"SAY"); Value v=eval_expr(rt,e); if(!rt->error) out_line(rt,v); value_free(v); free(e); return; } if(strcmp(up,"CLASS")==0){ int end=match_block(p,pc,"CLASS","ENDCLASS",NULL,NULL); *pcp=end<0?pc:end; return; } if(strcmp(up,"NEW")==0){ const char *as=find_word_ci(t,"AS"); if(as){ char *cls=substr(t+3,as); char *var=xstrdup(trim_inplace((char*)as+2)); create_object_value(rt,p->path,pc+1,trim_inplace(cls),trim_inplace(var)); free(cls); free(var); } else { const char *pcur=t+3; char *cls=unquote_token(&pcur); char *var=xstrdup(trim_inplace((char*)pcur)); create_object_value(rt,p->path,pc+1,trim_inplace(cls),trim_inplace(var)); free(cls); free(var); } return; } if(strcmp(up,"START")==0){ if(starts_ci(t+5," TASK")){ const char *pcur=t+10; char *name=unquote_token(&pcur); int end=match_block(p,pc,"START","ENDTASK",NULL,NULL); exec_range(rt,p,pc+1,end); if(name&&*name){ char done[256]; snprintf(done,sizeof(done),"TASK_%s_DONE",trim_inplace(name)); rt_set(rt,done,v_bool(1)); } *pcp=end<0?pc:end; free(name); return; } } @@ -424,9 +424,9 @@ static void exec_line(Runtime *rt,Program *p,int *pcp,const char *raw){ char buf if(strcmp(up,"OBJECT")==0){ const char *as=find_word_ci(t,"AS"); if(as && starts_ci(t+6," CLASS")){ char *oe=substr(t+12,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value ov=eval_expr(rt,oe); const char *cn=object_class_name(ov); rt_set_checked(rt,p->path,pc+1,var,"TEXT",v_str(cn?cn:"")); free(oe); free(var); } else if(as && starts_ci(t+6," FIELDS")){ char *oe=substr(t+13,as); char *var=xstrdup(trim_inplace((char*)as+2)); Value ov=eval_expr(rt,oe); Value arr=v_list(); int i; if(ov.type==V_MAP&&ov.map){ for(i=0;icount;i++){ if(strcmp(ov.map->keys[i],"class")!=0 && !starts_ci(ov.map->keys[i],"__type_")) list_add(arr.list,v_str(ov.map->keys[i])); } } rt_set_checked(rt,p->path,pc+1,var,"LIST",arr); free(oe); free(var); } else rt_error(rt,p->path,pc+1,"OBJECT needs CLASS or FIELDS. Try: OBJECT CLASS player AS kind"); return; } if(strcmp(up,"TYPE")==0 && starts_ci(t,"TYPE OF")){ const char *as=find_word_ci(t,"AS"); if(as){ char *expr=substr(t+7,as); const char *pcur=as+2; char *var=unquote_token(&pcur); char *extra=xstrdup(trim_inplace((char *)pcur)); if(extra&&*extra) rt_error(rt,p->path,pc+1,"TYPE OF %s has extra text after result name %s. Keep only the expression, AS, and one result name.",trim_inplace(expr),trim_inplace(var)); else { char dbuf[320]; const char *decl=declared_type_for_expr(rt,expr,dbuf,sizeof(dbuf)); Value v=eval_expr(rt,expr); rt_set_checked(rt,p->path,pc+1,var,"TEXT",v_str(decl?decl:claro_value_type(v))); } free(expr); free(var); free(extra); } else rt_error(rt,p->path,pc+1,"TYPE OF needs AS. Try: TYPE OF score AS kind"); return; } if(strcmp(up,"CHECK")==0 && starts_ci(t,"CHECK TYPE")){ const char *is=find_word_ci(t,"IS"); if(is){ char *expr=substr(t+10,is); char *ty=xstrdup(trim_inplace((char*)is+2)); char dbuf[320]; const char *decl=declared_type_for_expr(rt,expr,dbuf,sizeof(dbuf)); Value v=eval_expr(rt,expr); if(decl && !ci_eq(decl,ty) && !ci_eq(ty,"ANY")){ rt_error(rt,p->path,pc+1,"Type check failed: expected %s, but %s is %s.",ty,trim_inplace(expr),decl); } else if(!decl && !claro_type_matches(ty,v)){ rt_error(rt,p->path,pc+1,"Type check failed: expected %s, but got %s.",ty,claro_value_type(v)); } free(expr); free(ty); } else rt_error(rt,p->path,pc+1,"CHECK TYPE needs IS. Try: CHECK TYPE score IS NUMBER."); return; } - if(strcmp(up,"SET")==0){ char *rest=t+3; const char *to=find_word_ci(rest,"TO"); const char *as=find_word_ci(rest,"AS"); if(as&&to&&aspath,pc+1,n,ty,v); free(name); free(type); } - else if(to){ char *name=substr(rest,to); char *n=trim_inplace(name); char *space=strchr(n,' '); const char *type=NULL; if(space){ *space=0; if(claro_is_type_word(trim_inplace(space+1))) type=trim_inplace(space+1); else { *space=' '; } } { Value v=eval_expr(rt,to+2); rt_set_checked(rt,p->path,pc+1,n,type,v); } free(name); } - else { const char *pcur=rest; char *name=unquote_token(&pcur); char *n=trim_inplace(name); const char *save=pcur; char *maybe=unquote_token(&pcur); char *ty=trim_inplace(maybe); if(claro_is_type_word(ty)){ char *after=trim_inplace((char*)pcur); Value v=*after?eval_expr(rt,after):claro_default_for_type(ty); if(*n) rt_set_checked(rt,p->path,pc+1,n,ty,v); } else { Value v=eval_expr(rt,save); if(*n) rt_set_checked(rt,p->path,pc+1,n,NULL,v); } free(maybe); free(name); } return; } + if(strcmp(up,"SET")==0){ char *rest=t+3; const char *to=find_word_ci(rest,"TO"); const char *as=find_word_ci(rest,"AS"); if(as&&to&&aspath,pc+1,n,ty,v); value_free(v); free(name); free(type); } + else if(to){ char *name=substr(rest,to); char *n=trim_inplace(name); char *space=strchr(n,' '); const char *type=NULL; if(space){ *space=0; if(claro_is_type_word(trim_inplace(space+1))) type=trim_inplace(space+1); else { *space=' '; } } { Value v=eval_expr(rt,to+2); rt_set_checked(rt,p->path,pc+1,n,type,v); value_free(v); } free(name); } + else { const char *pcur=rest; char *name=unquote_token(&pcur); char *n=trim_inplace(name); const char *save=pcur; char *maybe=unquote_token(&pcur); char *ty=trim_inplace(maybe); if(claro_is_type_word(ty)){ char *after=trim_inplace((char*)pcur); Value v=*after?eval_expr(rt,after):claro_default_for_type(ty); if(*n) rt_set_checked(rt,p->path,pc+1,n,ty,v); value_free(v); } else { Value v=eval_expr(rt,save); if(*n) rt_set_checked(rt,p->path,pc+1,n,NULL,v); value_free(v); } free(maybe); free(name); } return; } if(strcmp(up,"ASK")==0){ const char *as=find_word_ci(t,"AS"); char input[1024]; char *prompt_text=NULL; char *var=NULL; char *type=NULL; if(as){ char *prompt_expr=substr(t+3,as); const char *pcur=as+2; var=unquote_token(&pcur); { char *rest=xstrdup(trim_inplace((char*)pcur)); if(claro_is_type_word(rest)) type=rest; else free(rest); } { Value prompt=eval_expr(rt,prompt_expr); prompt_text=v_to_string(prompt); } free(prompt_expr); } else { const char *pcur=t+3; prompt_text=unquote_token(&pcur); var=unquote_token(&pcur); { char *rest=xstrdup(trim_inplace((char*)pcur)); if(claro_is_type_word(rest)) type=rest; else free(rest); } } if(prompt_text&&prompt_text[0]){ if(rt->capture){ str_add(&rt->captured,prompt_text); str_ch(&rt->captured,'\n'); } else { printf("%s\n",prompt_text); fflush(stdout); } } if(var&&*trim_inplace(var)){ Value val=v_str(""); if(fgets(input,sizeof(input),stdin)){ size_t n=strlen(input); while(n&&(input[n-1]=='\n'||input[n-1]=='\r')) input[--n]=0; } else input[0]=0; if(type&&*type){ if(!claro_value_from_text(type,input,&val)){ rt_error(rt,p->path,pc+1,"%s needs %s input. Try a value like %s.",trim_inplace(var),type,ci_eq(type,"NUMBER")?"5":(ci_eq(type,"YESNO")?"YES":"text")); } else rt_set_checked(rt,p->path,pc+1,trim_inplace(var),type,val); } else rt_set_checked(rt,p->path,pc+1,trim_inplace(var),NULL,v_str(input)); } else rt_error(rt,p->path,pc+1,"ASK needs a variable name. Try: ASK \"What is your name?\" name"); free(prompt_text); free(var); free(type); return; } if(strcmp(up,"IF")==0){ int elsepos=-1; int end=match_block(p,pc,"IF","ENDIF","ELSE",&elsepos); char *e=expr_after_word(t,"IF"); Value cond=eval_expr(rt,e); free(e); if(v_truth(cond)) exec_range(rt,p,pc+1,elsepos>=0?elsepos:end); else if(elsepos>=0) exec_range(rt,p,elsepos+1,end); *pcp=end<0?pc:end; return; } if(strcmp(up,"DO")==0){ int end=find_done(p,pc); const char *times=find_word_ci(t,"TIMES"); if(!times){ const char *pcur=t+2; char *name=unquote_token(&pcur); char **parts=NULL; int ac=0,i; Value *args=NULL; char *n=trim_inplace(name); if(*trim_inplace((char*)pcur)) ac=split_args(pcur,&parts); args=(Value*)xmalloc(sizeof(Value)*(ac?ac:1)); for(i=0;ierror && !rt->returning;i++) exec_range(rt,p,pc+1,end); *pcp=end<0?pc:end; return; } } diff --git a/tools/validate_expression_cleanup.py b/tools/validate_expression_cleanup.py new file mode 100644 index 0000000..5b00d40 --- /dev/null +++ b/tools/validate_expression_cleanup.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Regression check for discarded expression Value cleanup under LeakSanitizer.""" +from pathlib import Path +import os +import subprocess +import sys +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-expression-cleanup-") as tmp: + tmp_path = Path(tmp) + binary = tmp_path / "claro-lsan" + script = tmp_path / "expressions.claro" + script.write_text( + 'SET value TO "seed"\n' + + ''.join( + 'SET value TO "a" + "b"\n' + 'SET answer TO 1 + 2 * 3\n' + for _ in range(2000) + ) + + 'SAY value\n', + 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="", file=sys.stderr) + 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: + print("FAIL: expression temporary cleanup probe failed") + print(run.stdout, end="") + print(run.stderr, end="", file=sys.stderr) + return 1 + if "LeakSanitizer" in run.stderr or "SUMMARY:" in run.stderr: + print("FAIL: discarded expression values still leak") + print(run.stderr, end="", file=sys.stderr) + return 1 + if run.stdout != "ab\n": + print("FAIL: expression cleanup probe produced the wrong output") + print(run.stdout, end="", file=sys.stderr) + return 1 + print("PASS: discarded expression values are released") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())