diff --git a/compiler/README.md b/compiler/README.md index db217a7..04debd5 100644 --- a/compiler/README.md +++ b/compiler/README.md @@ -37,12 +37,25 @@ cargo test --manifest-path compiler/Cargo.toml -p orangec --test s3a_conformance `orangec` accepts up to 256 source inputs in argument order. Regular files are processed incrementally; `-` is the only stream input and reads standard input at most once. `eval` accepts exactly one source and emits no partial result after -an error. Successful `check` commands are silent. Diagnostics go to standard -error and use exit status 1; command-line usage errors use status 2. A source +a language or evaluation error. Successful `check` commands are silent. +Diagnostics go to standard error and use exit status 1; command-line usage +errors use status 2. A source with lexical errors is not parsed, and a source with syntax errors is not -analyzed. File and standard-input reads stop at a deterministic 16 MiB -per-source limit. Larger inputs fail with `ORC1003` before lexing and are never -buffered without a bound. +analyzed. Output I/O failures use status 1; a broken pipe remains quiet but is +not reported as successful evaluation. Displayed source names and echoed +command or option text escape control and non-ASCII data; invalid encoded path +bytes use `\xNN` escapes instead of lossy replacement. File and standard-input +reads stop at a deterministic 16 MiB per-source limit. Larger inputs fail with +`ORC1003` before lexing and are never buffered without a bound. + +Accepted S3a has no separate evaluation-output byte limit. Each successful +output line repeats the module name, so a source with a long module name and +many typed specifications can request output much larger than its input. The +CLI streams values only after complete analysis and evaluation, which bounds +compiler-owned output buffering but does not bound the requested bytes or time. +Apply caller-side output and time limits before using `orangec eval` on +untrusted sources. Adding a fail-closed output ceiling requires an explicit +edition-aware semantic decision because it would change accepted S3a behavior. ## Frozen lexical boundary diff --git a/compiler/crates/orange-compiler/src/diagnostic.rs b/compiler/crates/orange-compiler/src/diagnostic.rs index f80a08e..bda7904 100644 --- a/compiler/crates/orange-compiler/src/diagnostic.rs +++ b/compiler/crates/orange-compiler/src/diagnostic.rs @@ -447,13 +447,13 @@ fn render_excerpt( if left_truncated { excerpt.push_str("... "); } - excerpt.push_str(&sanitize_inline(&line[window_start..window_end])); + excerpt.push_str(&sanitize_source_inline(&line[window_start..window_end])); if right_truncated { excerpt.push_str(" ..."); } - let caret_offset = - usize::from(left_truncated) * 4 + sanitized_width(&line[window_start..relative_start]); + let caret_offset = usize::from(left_truncated) * 4 + + sanitized_source_width(&line[window_start..relative_start]); let relative_end = usize::try_from(span.end().bytes().saturating_sub(line_start.bytes())) .unwrap_or(line.len()) .min(window_end) @@ -463,7 +463,7 @@ fn render_excerpt( { 1 } else { - sanitized_width(&line[relative_start..relative_end]).max(1) + sanitized_source_width(&line[relative_start..relative_end]).max(1) }; (excerpt, caret_offset, caret_width) } @@ -471,9 +471,7 @@ fn render_excerpt( fn sanitize_inline(text: &str) -> String { let mut sanitized = String::new(); for character in text.chars() { - if character == '\t' { - sanitized.push_str(" "); - } else if character.is_control() || is_bidi_control(character) { + if !character.is_ascii_graphic() && character != ' ' { sanitized.extend(character.escape_default()); } else { sanitized.push(character); @@ -482,12 +480,22 @@ fn sanitize_inline(text: &str) -> String { sanitized } -fn sanitized_width(text: &str) -> usize { +fn sanitize_source_inline(text: &str) -> String { + let mut sanitized = String::new(); + for character in text.chars() { + if character == '\\' || (!character.is_ascii_graphic() && character != ' ') { + sanitized.extend(character.escape_default()); + } else { + sanitized.push(character); + } + } + sanitized +} + +fn sanitized_source_width(text: &str) -> usize { text.chars() .map(|character| { - if character == '\t' { - 4 - } else if character.is_control() || is_bidi_control(character) { + if character == '\\' || (!character.is_ascii_graphic() && character != ' ') { character.escape_default().count() } else { 1 @@ -496,13 +504,6 @@ fn sanitized_width(text: &str) -> usize { .sum() } -const fn is_bidi_control(character: char) -> bool { - matches!( - character, - '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' - ) -} - #[cfg(test)] mod tests { use super::*; @@ -524,17 +525,51 @@ mod tests { assert_eq!( render_diagnostics(&sources, &[diagnostic]), - concat!( - "error[ORC0001]: unexpected character '@'\n", - " --> sample.or:1:7\n", - " |\n", - "1 | let é@\n", - " | ^ character is not part of Orange 2026\n", - " = note: identifiers are ASCII in this pre-alpha edition\n", + format!( + concat!( + "error[ORC0001]: unexpected character '@'\n", + " --> sample.or:1:7\n", + " |\n", + "1 | \\tlet \\u{{e9}}@\n", + " | {caret}^ character is not part of Orange 2026\n", + " = note: identifiers are ASCII in this pre-alpha edition\n", + ), + caret = " ".repeat(12), ) ); } + #[test] + fn escaping_distinguishes_tabs_spaces_backslashes_and_unicode() { + let source = "\t\\ é"; + let sanitized = sanitize_source_inline(source); + + assert_eq!(sanitized, r"\t\\ \u{e9}"); + assert_eq!(sanitized_source_width(source), sanitized.chars().count()); + } + + #[test] + fn escapes_non_ascii_source_text_with_matching_caret_width() { + let mut sources = SourceMap::new(); + let text = "a\u{200b}🟠@\n"; + let id = sources.add("sample.or", text).unwrap(); + let source = sources.get(id).unwrap(); + let at = u32::try_from(text.find('@').unwrap()).unwrap(); + let span = source + .span(TextOffset::new(at), TextOffset::new(at + 1)) + .unwrap(); + let diagnostic = Diagnostic::error( + DiagnosticCode::UnexpectedCharacter, + "unexpected character '@'", + span, + ); + + let rendered = render_diagnostics(&sources, &[diagnostic]); + assert!(rendered.is_ascii()); + assert!(rendered.contains("1 | a\\u{200b}\\u{1f7e0}@\n")); + assert!(rendered.contains(&format!(" | {}^\n", " ".repeat(18)))); + } + #[test] fn sorts_diagnostics_by_source_position_then_code() { let mut sources = SourceMap::new(); diff --git a/compiler/crates/orange-compiler/src/eval.rs b/compiler/crates/orange-compiler/src/eval.rs index 18bcee3..3031905 100644 --- a/compiler/crates/orange-compiler/src/eval.rs +++ b/compiler/crates/orange-compiler/src/eval.rs @@ -1,6 +1,7 @@ //! Deterministic reference evaluation for typed Orange Core. use std::fmt; +use std::sync::Arc; use crate::core::{CoreFunctionId, CoreModule, CoreType, CoreValue}; use crate::diagnostic::{Diagnostic, DiagnosticCode}; @@ -14,7 +15,7 @@ pub struct EvaluatedFunction { /// Identity copied from the source-ordered Core function. pub id: CoreFunctionId, /// Exact ASCII module name. - pub module: String, + pub module: Arc, /// Exact ASCII function name. pub name: String, /// Statically checked result type. @@ -57,7 +58,8 @@ pub fn evaluate(core: &CoreModule) -> EvaluationResult { } fn evaluate_with_limit(core: &CoreModule, step_limit: usize) -> EvaluationResult { - let mut values = Vec::with_capacity(core.functions.len()); + let mut values = Vec::with_capacity(core.functions.len().min(step_limit)); + let module: Arc = Arc::from(core.name.as_str()); for (steps, function) in core.functions.iter().enumerate() { if steps >= step_limit { return EvaluationResult { @@ -78,7 +80,7 @@ fn evaluate_with_limit(core: &CoreModule, step_limit: usize) -> EvaluationResult } values.push(EvaluatedFunction { id: function.id, - module: core.name.clone(), + module: Arc::clone(&module), name: function.name.clone(), result_type: function.result_type, value: function.value.clone(), @@ -169,4 +171,16 @@ mod tests { let core = core("edition 2026; module values { spec answer() -> Int { 42 } }\n"); assert_eq!(evaluate(&core), evaluate(&core)); } + + #[test] + fn module_identity_is_shared_across_evaluated_values() { + let core = core(concat!( + "edition 2026; module a_very_long_shared_module_name {\n", + " spec first() -> Int { 1 }\n", + " spec second() -> Int { 2 }\n", + "}\n", + )); + let values = evaluate(&core).values.unwrap(); + assert!(Arc::ptr_eq(&values[0].module, &values[1].module)); + } } diff --git a/compiler/crates/orangec/src/main.rs b/compiler/crates/orangec/src/main.rs index 10687d3..8febaee 100644 --- a/compiler/crates/orangec/src/main.rs +++ b/compiler/crates/orangec/src/main.rs @@ -2,7 +2,6 @@ use std::env; use std::ffi::{OsStr, OsString}; -use std::fmt::Write as _; use std::fs::File; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -53,8 +52,11 @@ fn run( let action = match parse_arguments(arguments) { Ok(action) => action, Err(message) => { - let _ = writeln!(standard_error, "orangec: {message}\n\n{USAGE}"); - return USAGE_ERROR; + return if writeln!(standard_error, "orangec: {message}\n\n{USAGE}").is_err() { + COMPILATION_ERROR + } else { + USAGE_ERROR + }; } }; @@ -95,11 +97,10 @@ fn compile( let mut output_failed = false; let mut standard_error_available = true; let mut error_group_written = false; - let mut token_output_available = true; + let mut standard_output_available = true; let mut token_source_written = false; - let mut evaluation_output = String::new(); let show_headers = options.paths.len() > 1; - let mut token_output = io::BufWriter::new(standard_output); + let mut buffered_output = io::BufWriter::new(standard_output); for path in &options.paths { if path == Path::new("-") { @@ -213,9 +214,9 @@ fn compile( .expect("a source must be available immediately after insertion"); let result = lex(source, options.edition); - if options.command == CompilerCommand::Lex && token_output_available { + if options.command == CompilerCommand::Lex && standard_output_available { match write_tokens( - &mut token_output, + &mut buffered_output, source, &result, show_headers, @@ -223,9 +224,9 @@ fn compile( ) { Ok(()) => token_source_written = true, Err(error) => { - token_output_available = false; + standard_output_available = false; + output_failed = true; if error.kind() != io::ErrorKind::BrokenPipe { - output_failed = true; emit_error_group( standard_error, &mut standard_error_available, @@ -324,48 +325,49 @@ fn compile( compilation_failed = true; continue; }; - for value in values { - let _ = writeln!(evaluation_output, "{value}"); + // Argument validation guarantees exactly one `eval` source, so + // no later source can invalidate output after this point. + if standard_output_available { + for value in values { + if let Err(error) = writeln!(buffered_output, "{value}") { + standard_output_available = false; + output_failed = true; + if error.kind() != io::ErrorKind::BrokenPipe { + emit_error_group( + standard_error, + &mut standard_error_available, + &mut error_group_written, + &mut output_failed, + "orangec: could not write evaluation output\n", + ); + } + break; + } + } } } } } - if !compilation_failed - && options.command == CompilerCommand::Eval - && token_output_available - && let Err(error) = token_output.write_all(evaluation_output.as_bytes()) - { - token_output_available = false; + if standard_output_available && let Err(error) = buffered_output.flush() { + output_failed = true; if error.kind() != io::ErrorKind::BrokenPipe { - output_failed = true; emit_error_group( standard_error, &mut standard_error_available, &mut error_group_written, &mut output_failed, - "orangec: could not write evaluation output\n", + if options.command == CompilerCommand::Eval { + "orangec: could not write evaluation output\n" + } else { + "orangec: could not write token output\n" + }, ); } } - - if token_output_available - && let Err(error) = token_output.flush() - && error.kind() != io::ErrorKind::BrokenPipe - { - output_failed = true; - emit_error_group( - standard_error, - &mut standard_error_available, - &mut error_group_written, - &mut output_failed, - if options.command == CompilerCommand::Eval { - "orangec: could not write evaluation output\n" - } else { - "orangec: could not write token output\n" - }, - ); - } + // Do not let `BufWriter`'s best-effort drop path retry retained bytes after + // an output failure. A successful explicit flush leaves nothing to discard. + let (_standard_output, _unwritten_output) = buffered_output.into_parts(); if output_failed || compilation_failed { COMPILATION_ERROR @@ -453,13 +455,25 @@ fn stable_source_name(path: &Path) -> String { if path == Path::new("-") { return String::from(""); } - path.as_os_str() - .to_string_lossy() - .chars() - .flat_map(char::escape_default) + let name = path.as_os_str(); + if let Some(name) = name.to_str() { + return escape_display_text(name); + } + + // Preserve the target's encoded bytes so distinct non-UTF-8 paths cannot + // collapse to the same replacement-character display. + name.as_encoded_bytes() + .iter() + .copied() + .flat_map(std::ascii::escape_default) + .map(char::from) .collect() } +fn escape_display_text(text: &str) -> String { + text.chars().flat_map(char::escape_default).collect() +} + fn source_limit_error(path: &Path, error: SourceError) -> String { render_cli_error( "ORC1005", @@ -570,7 +584,7 @@ fn parse_arguments(arguments: impl IntoIterator) -> Result { - return Err(format!("unknown option `{value}`")); + return Err(format!("unknown option `{}`", escape_display_text(value))); } _ => {} } @@ -581,7 +595,9 @@ fn parse_arguments(arguments: impl IntoIterator) -> Result Some(CompilerCommand::Check), Some("eval") => Some(CompilerCommand::Eval), Some("lex") => Some(CompilerCommand::Lex), - Some(value) => return Err(format!("unknown command `{value}`")), + Some(value) => { + return Err(format!("unknown command `{}`", escape_display_text(value))); + } None => return Err(String::from("command is not valid UTF-8")), }; } else { @@ -629,6 +645,58 @@ fn parse_edition(value: &OsStr) -> Result { mod tests { use super::*; + struct RejectWrites(io::ErrorKind); + + impl Write for RejectWrites { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::from(self.0)) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[derive(Default)] + struct FailFirstWrite { + attempts: usize, + bytes: Vec, + } + + impl Write for FailFirstWrite { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.attempts += 1; + if self.attempts == 1 { + Err(io::Error::from(io::ErrorKind::Other)) + } else { + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[derive(Default)] + struct MeasureWrites { + bytes: usize, + largest_write: usize, + } + + impl Write for MeasureWrites { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.bytes += buffer.len(); + self.largest_write = self.largest_write.max(buffer.len()); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + fn os_arguments(values: &[&str]) -> Vec { values.iter().map(OsString::from).collect() } @@ -672,6 +740,19 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn non_utf8_source_names_are_escaped_without_lossy_aliases() { + use std::os::unix::ffi::OsStringExt as _; + + let first = PathBuf::from(OsString::from_vec(b"source-\x80.or".to_vec())); + let second = PathBuf::from(OsString::from_vec(b"source-\x81.or".to_vec())); + + assert_eq!(stable_source_name(&first), "source-\\x80.or"); + assert_eq!(stable_source_name(&second), "source-\\x81.or"); + assert_ne!(stable_source_name(&first), stable_source_name(&second)); + } + #[test] fn reports_missing_inputs_and_unknown_editions() { assert_eq!( @@ -688,6 +769,39 @@ mod tests { ); } + #[test] + fn escapes_untrusted_command_and_option_text() { + assert_eq!( + parse_arguments(os_arguments(&["bad\ncommand", "source.or"])), + Err(String::from("unknown command `bad\\ncommand`")) + ); + assert_eq!( + parse_arguments(os_arguments(&["check", "--bad\u{1b}[31m", "source.or"])), + Err(String::from("unknown option `--bad\\u{1b}[31m`")) + ); + assert_eq!( + parse_arguments(os_arguments(&["check", "--bad\u{202e}", "source.or"])), + Err(String::from("unknown option `--bad\\u{202e}`")) + ); + } + + #[test] + fn usage_output_failure_has_compilation_status() { + let mut input = b"".as_slice(); + let mut output = Vec::new(); + let mut error = RejectWrites(io::ErrorKind::BrokenPipe); + + let status = run( + os_arguments(&["unknown"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, COMPILATION_ERROR); + assert_eq!(output, b""); + } + #[test] fn bounds_source_inputs_per_invocation() { let mut at_limit = vec![OsString::from("check")]; @@ -727,6 +841,104 @@ mod tests { ); } + #[test] + fn evaluation_output_failure_has_a_stable_status_and_diagnostic() { + let source = b"edition 2026; module values { spec answer() -> Int { 42 } }\n"; + let mut input = source.as_slice(); + let mut output = RejectWrites(io::ErrorKind::Other); + let mut error = Vec::new(); + + let status = run( + os_arguments(&["eval", "-"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, COMPILATION_ERROR); + assert_eq!(error, b"orangec: could not write evaluation output\n"); + } + + #[test] + fn evaluation_output_failure_is_not_retried_during_teardown() { + let source = b"edition 2026; module values { spec answer() -> Int { 42 } }\n"; + let mut input = source.as_slice(); + let mut output = FailFirstWrite::default(); + let mut error = Vec::new(); + + let status = run( + os_arguments(&["eval", "-"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, COMPILATION_ERROR); + assert_eq!(error, b"orangec: could not write evaluation output\n"); + assert_eq!(output.attempts, 1); + assert_eq!(output.bytes, b""); + } + + #[test] + fn evaluation_broken_pipe_is_a_quiet_failure() { + let source = b"edition 2026; module values { spec answer() -> Int { 42 } }\n"; + let mut input = source.as_slice(); + let mut output = RejectWrites(io::ErrorKind::BrokenPipe); + let mut error = Vec::new(); + + let status = run( + os_arguments(&["eval", "-"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, COMPILATION_ERROR); + assert_eq!(error, b""); + } + + #[test] + fn token_output_broken_pipe_is_a_quiet_failure() { + let mut input = b"edition 2026; module values {}\n".as_slice(); + let mut output = RejectWrites(io::ErrorKind::BrokenPipe); + let mut error = Vec::new(); + + let status = run( + os_arguments(&["lex", "-"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, COMPILATION_ERROR); + assert_eq!(error, b""); + } + + #[test] + fn evaluation_output_is_streamed_by_value() { + let module = "m".repeat(16 * 1024); + let source = format!( + "edition 2026; module {module} {{ \ + spec first() -> Int {{ 1 }} spec second() -> Int {{ 2 }} }}\n" + ); + let expected_bytes = format!("{module}::first: Int = 1\n{module}::second: Int = 2\n").len(); + let mut input = source.as_bytes(); + let mut output = MeasureWrites::default(); + let mut error = Vec::new(); + + let status = run( + os_arguments(&["eval", "-"]), + &mut input, + &mut output, + &mut error, + ); + + assert_eq!(status, SUCCESS); + assert_eq!(error, b""); + assert_eq!(output.bytes, expected_bytes); + assert!(output.largest_write < output.bytes); + } + impl Action { fn compile_options(self) -> Options { match self { diff --git a/compiler/crates/orangec/tests/cli.rs b/compiler/crates/orangec/tests/cli.rs index b7f27ea..c5923a1 100644 --- a/compiler/crates/orangec/tests/cli.rs +++ b/compiler/crates/orangec/tests/cli.rs @@ -1,11 +1,16 @@ use std::fs; use std::fs::File; -use std::io::Write; +use std::io::{Read, Write}; use std::path::PathBuf; use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use orange_compiler::MAX_SOURCE_BYTES; +const HOSTILE_CAPTURE_LIMIT_BYTES: usize = 2 * 1024 * 1024; +const HOSTILE_TIME_LIMIT: Duration = Duration::from_secs(5); + fn orangec() -> Command { Command::new(env!("CARGO_BIN_EXE_orangec")) } @@ -30,6 +35,67 @@ fn run_with_stdin(arguments: &[&str], input: &[u8]) -> std::process::Output { child.wait_with_output().unwrap() } +fn run_hostile_with_stdin(arguments: &[&str], input: &[u8]) -> std::process::Output { + let mut child = orangec() + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let capture_bytes = u64::try_from(HOSTILE_CAPTURE_LIMIT_BYTES + 1).unwrap(); + let stdout_reader = thread::spawn(move || { + let mut bytes = Vec::new(); + stdout + .by_ref() + .take(capture_bytes) + .read_to_end(&mut bytes) + .unwrap(); + bytes + }); + let stderr_reader = thread::spawn(move || { + let mut bytes = Vec::new(); + stderr + .by_ref() + .take(capture_bytes) + .read_to_end(&mut bytes) + .unwrap(); + bytes + }); + + let deadline = Instant::now() + HOSTILE_TIME_LIMIT; + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + panic!("orangec exceeded the hostile-input time limit for {arguments:?}"); + } + thread::sleep(Duration::from_millis(2)); + }; + + std::process::Output { + status, + stdout: stdout_reader.join().unwrap(), + stderr: stderr_reader.join().unwrap(), + } +} + +fn next_corpus_index(state: &mut u64, upper_bound: usize) -> usize { + *state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + usize::try_from(*state % u64::try_from(upper_bound).unwrap()).unwrap() +} + #[test] fn checks_the_permanent_fixture_without_output() { let output = orangec().arg("check").arg(fixture()).output().unwrap(); @@ -286,8 +352,8 @@ fn check_reports_a_stable_source_diagnostic_and_failure_status() { "error[ORC0001]: unexpected character U+00E9\n", " --> :1:11\n", " |\n", - "1 | module café {}\n", - " | ^ character is not part of Orange 2026\n", + "1 | module caf\\u{e9} {}\n", + " | ^^^^^^ character is not part of Orange 2026\n", " = note: identifiers are ASCII in this pre-alpha edition\n", ) ); @@ -391,3 +457,149 @@ fn usage_errors_have_a_distinct_exit_status() { let stderr = String::from_utf8(output.stderr).unwrap(); assert!(stderr.starts_with("orangec: unknown command `compile`\n\nUsage:")); } + +#[test] +fn generated_hostile_inputs_are_bounded_and_repeatable_across_commands() { + const CASES: usize = 64; + const INVALID_LEXICAL_FRAGMENTS: &[&str] = &[ + "0x__", + "/* unterminated", + "\"bad \\x0Z\"", + "é", + "β", + "\u{202e}", + "\0", + ]; + const LEXICALLY_VALID_FRAGMENTS: &[&str] = &[ + " ", + "\t", + "\n", + "\r", + "\r\n", + "edition", + "2026", + "module", + "spec", + "impl", + "game", + "proof", + "claim", + "name", + "_x", + "Int", + "Word", + "0", + "-0", + "42", + "0xff", + "0b1010", + "(", + ")", + "{", + "}", + "[", + "]", + ",", + ":", + ";", + "->", + "=>", + "..", + "::", + "+", + "-", + "*", + "/", + "/* nested /* comment */ tail */", + "// line comment\n", + "\"ok\\n\"", + ]; + + let mut state = 0x000a_6a9e_2026_d003_u64; + for case in 0..CASES { + let class = case % 4; + let source = match class { + 0 => { + let mut source = String::new(); + let fragments = 1 + next_corpus_index(&mut state, 64); + for _ in 0..fragments { + let fragment = if next_corpus_index(&mut state, 4) == 0 { + INVALID_LEXICAL_FRAGMENTS + [next_corpus_index(&mut state, INVALID_LEXICAL_FRAGMENTS.len())] + } else { + LEXICALLY_VALID_FRAGMENTS + [next_corpus_index(&mut state, LEXICALLY_VALID_FRAGMENTS.len())] + }; + source.push_str(fragment); + } + source.push('é'); + source + } + 1 => { + let mut source = String::from("edition 2026; module generated {"); + let fragments = 1 + next_corpus_index(&mut state, 64); + for _ in 0..fragments { + source.push_str( + LEXICALLY_VALID_FRAGMENTS + [next_corpus_index(&mut state, LEXICALLY_VALID_FRAGMENTS.len())], + ); + source.push(' '); + } + source.push_str("spec broken( }"); + source + } + 2 => format!( + "edition 2026; module generated {{ spec valid_{case}() -> Int {{ 42 }} \ + spec invalid_{case}() -> Word[8] {{ 256 }} }}" + ), + 3 => { + let word = next_corpus_index(&mut state, 256); + format!( + "edition 2026; module generated {{ spec integer_{case}() -> Int {{ -42 }} \ + spec byte_{case}() -> Word[8] {{ {word} }} impl empty_{case}() {{}} }}" + ) + } + _ => unreachable!(), + }; + + for command in ["lex", "check", "eval"] { + let first = run_hostile_with_stdin(&[command, "-"], source.as_bytes()); + let second = run_hostile_with_stdin(&[command, "-"], source.as_bytes()); + let expected_status = if class == 3 || command == "lex" && class != 0 { + Some(0) + } else { + Some(1) + }; + assert_eq!( + first.status.code(), + second.status.code(), + "nondeterministic status for generated case {case} under {command}" + ); + assert_eq!( + first.stdout, second.stdout, + "nondeterministic stdout for generated case {case} under {command}" + ); + assert_eq!( + first.stderr, second.stderr, + "nondeterministic stderr for generated case {case} under {command}" + ); + assert_eq!( + first.status.code(), + expected_status, + "unexpected pipeline result for generated case {case} under {command}" + ); + assert!( + first.stdout.is_ascii(), + "non-ASCII stdout for generated case {case} under {command}" + ); + assert!( + first.stderr.is_ascii(), + "non-ASCII stderr for generated case {case} under {command}" + ); + assert!( + first.stdout.len() + first.stderr.len() <= HOSTILE_CAPTURE_LIMIT_BYTES, + "unbounded output for generated case {case} under {command}" + ); + } + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e1bff43..2b06abd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -305,8 +305,10 @@ The unapproved [D-003 product-form decision packet](PRODUCT_FORM_DECISION_PACKET.md) and the conditional [D-004 semantic-strata decision suite](SEMANTIC_STRATA_DECISION_SUITE.md) -define the owner-executable research that may run ahead. Neither packet decides -its subject or authorizes S3b; the D-004 suite retains a zero-evidence baseline. +define the owner-reviewable decision research that may run ahead. D-003 intake +and D-004 pre-freeze closure may proceed, but measured D-004 execution awaits +recorded conditional authority and a frozen packet. Neither packet decides its +subject or authorizes S3b; the D-004 suite retains a zero-evidence baseline. ## 7. Quality and claim metrics diff --git a/docs/SEMANTIC_STRATA_DECISION_SUITE.md b/docs/SEMANTIC_STRATA_DECISION_SUITE.md index c9c7c1c..e128ed3 100644 --- a/docs/SEMANTIC_STRATA_DECISION_SUITE.md +++ b/docs/SEMANTIC_STRATA_DECISION_SUITE.md @@ -1,9 +1,9 @@ # D-004 semantic-strata decision suite -Status: draft owner-executable decision protocol; no semantic-strata candidate -selected +Status: draft pre-freeze decision protocol; no semantic-strata candidate +selected and no evidence epoch authorized -Suite version: `d004-v0.1-draft` +Suite version: `d004-v0.2-draft` Snapshot: 2026-07-13 @@ -78,6 +78,31 @@ The candidate packet freezes: - a correction window that applies equally to all candidates; and - a variance log in which any changed premise creates a new evidence epoch. +### 2.1 Freeze-readiness review + +The preceding list is a protocol requirement, not a statement that a frozen +packet already exists. The five prose cases are sufficient to review the +experiment's intended scope, but they are not executable fixtures. An evidence +epoch cannot start until the following closure artifacts exist at one exact +repository revision. + +| ID | Blocking pre-freeze item | Required closure artifact | +| --- | --- | --- | +| FR-01 | The relationship table uses `ST-REL` names even though four candidates may organize the same obligations differently. | One neutral adapter contract plus a complete mapping from every candidate's internal members and routes to the required external observations. | +| FR-02 | Case inputs and expected observations are prose, not exact bytes. | A byte manifest with paths, modes, SHA-256 digests, model identities, and expected observation records for every positive and negative run. | +| FR-03 | Mutations have stable names below but no fixture identities or provenance. | One immutable fixture per mutation ID, linked to its base fixture and exact changed byte or structured field; generated fixtures also bind the generator and seed. | +| FR-04 | Section 7 enumerates result fields but no versioned suite record schema exists. | A versioned suite-index, run-result, and reproduction schema, or an admitted general evidence schema that expresses every required field without free-form substitutes, with positive, negative, and migration fixtures. | +| FR-05 | Replay principles do not yet identify an executable runner or resource observer. | Content-identified runner and observer artifacts specifying process-tree accounting, timeout and kill behavior, output counting, cache cleanup, temporary-storage accounting, and unsupported hosts. | +| FR-06 | The packet does not yet bind exact argument vectors, working directories, or the complete affecting environment. | A replay manifest per run and an environment allowlist whose omissions fail closed. | +| FR-07 | Candidate work order and correction events could create asymmetric learning or repair. | A preregistered authoring and replay order, equal candidate-local correction rules, retained failed runs, and a rule that any shared repair starts a new epoch for all candidates. | +| FR-08 | The conclusion rule permits a later distinguishing rule but does not make post-hoc within-epoch selection explicitly invalid. | A frozen decision record template stating that a new distinguishing rule changes the suite version, creates a new epoch, and reruns the complete common packet. | +| FR-09 | D-003, dependency admission, and top-level research inventory are not yet disposed for execution. | Owner-recorded conditional-research authority, the current D-003 dependency state, admitted tool/dependency terms, and the policy change that admits the real evidence tree. | + +Freeze readiness is not currently established. These nine items measure +protocol closure, not candidate quality, and no candidate may gain execution +evidence by implementing against an unfrozen draft. Closing an item requires +the named artifact; restating its requirement in prose is not closure. + ## 3. Proposed role map The role map below is a hypothesis to test, not accepted semantics. @@ -120,9 +145,18 @@ executable proof semantics and chooses no Proof IR. ## 4. Required relationship graph Every candidate must express the following crossings or a demonstrably -equivalent graph. Each edge has a versioned name, domain, codomain, definedness -conditions, obligations, identity inputs, trust role, failure behavior, and -prohibited reverse inferences. +equivalent graph. The crossing names use the `ST-REL` hypothesis as readable +shorthand; they do not require another candidate to copy its node count, +internal names, or representation boundaries. Each edge has a versioned name, +domain, codomain, definedness conditions, obligations, identity inputs, trust +role, failure behavior, and prohibited reverse inferences. + +Before a packet freezes, every candidate must provide an adapter map for each +`SR-*` row. The map records the source role, authoritative meaning, required +external observations, candidate-internal route, failure point, and dependent +results invalidated by failure. Equivalent graphs are judged on those frozen +boundary behaviors. A missing `ST-REL`-named internal node is not itself a +failure, and a same-named node is not evidence that the behavior exists. | ID | Required crossing | Mandatory boundary behavior | | --- | --- | --- | @@ -161,6 +195,25 @@ records inputs, expected observations, positive and negative outcomes, exact dependencies, resource use, and a falsification condition. A prose-only claim that a case is representable is not execution evidence. +The variant IDs below are stable suite identities. `P00` is the positive run; +each `M*` ID is one independently replayed mutation of that case's positive +fixture. They name intended changes but do not become frozen inputs until +FR-02 and FR-03 close. + +| Case | Positive variant | Mutation variants | +| --- | --- | --- | +| SC-01 | `SC-01-P00` exact word operations | `SC-01-M01` implicit integer-to-word conversion; `SC-01-M02` implicit endian conversion; `SC-01-M03` width mismatch; `SC-01-M04` unbounded shift | +| SC-02 | `SC-02-P00` valid in-place transformation | `SC-02-M01` illegal alias; `SC-02-M02` out-of-range access; `SC-02-M03` missing loop invariant; `SC-02-M04` uninitialized read; `SC-02-M05` wrong refinement subject | +| SC-03 | `SC-03-P00` public-control implementation | `SC-03-M01` secret-dependent branch; `SC-03-M02` secret-dependent address; `SC-03-M03` secret-dependent loop bound; `SC-03-M04` secret-dependent failure path; `SC-03-M05` secret-dependent debug observation | +| SC-04 | `SC-04-P00` supported vector operation | `SC-04-M01` missing feature; `SC-04-M02` unsupported intrinsic; `SC-04-M03` lane-order mismatch; `SC-04-M04` width mismatch; `SC-04-M05` target-identity substitution; `SC-04-M06` undeclared fallback | +| SC-05 | `SC-05-P00` explicit finite experiments and reduction | `SC-05-M01` sampling in Specification; `SC-05-M02` ambient randomness; `SC-05-M03` hidden oracle; `SC-05-M04` unbounded sample; `SC-05-M05` subject substitution; `SC-05-M06` altered bound | + +A case passes only when its `P00` variant and every listed mutation complete +with their frozen expected observations. Resource-exhaustion, missing-input, +digest-mismatch, crash, and unsupported-path fixtures receive additional IDs +when the executable packet closes; they must not be hidden inside a listed +semantic mutation. + ### SC-01 — SHA-like word code **Question:** Can the candidate express pure SHA-like word operations without @@ -395,6 +448,24 @@ focused work recorded by the owner; automation runs are separately bounded by each case. Exceeding a budget records non-success. Changing these budgets after candidate work starts creates a new epoch and restarts every candidate. +For resource accounting, one replay is one candidate, one case, and one +variant ID. The stated 15-minute wall-time, 4-GiB peak-resident-memory, 2-GiB +temporary-storage, and 256-MiB captured-output ceilings apply independently to +every replay, including resource and unsupported fixtures. Captured output is +the combined uncompressed stdout, stderr, and runner-owned raw log bytes before +normalization. Peak memory and timeout cover the complete descendant process +tree; temporary storage covers every candidate-specific writable path. The +frozen observer defines platform-specific enforcement, termination, descendant +cleanup, and what happens when a measurement cannot be made reliably. + +Candidate-specific authoring, debugging, and inspection consume that +candidate's owner-hour budget. Work on the common protocol consumes neither +candidate budget. Waiting for unattended automation does not consume focused +owner-hours, but inspecting or repairing its result does. A correction may +change only candidate-local artifacts under the frozen contract and retains +the failed record. A shared fixture, observer, schema, rubric, resource rule, +or decision-rule change creates a new epoch and resets all candidate evidence. + Automated replay uses argument vectors rather than shell strings, a declared allowlisted environment, pinned tool and input digests, network denied, an empty candidate-specific cache, deterministic output manifests, and explicit @@ -432,6 +503,12 @@ does not accept D-004. If zero or multiple candidates pass, the result is `inconclusive` until the owner records a non-compensable distinguishing rule or revises and reruns the common suite. +A distinguishing rule cannot select a candidate from the epoch in which it was +invented. It changes the suite version, starts a new evidence epoch, and applies +to the complete common packet for all candidates. Candidate-specific evidence +cannot be carried forward unless its bytes and every affecting premise remain +identical and the new rule explicitly admits that reuse for every candidate. + Acceptance requires: - accepted disposition of D-003 and its product-form record; @@ -454,9 +531,9 @@ Execution evidence is currently 0/5 candidates and 0/5 cases. ## 9. Current handoff The next authorized actions are to obtain owner intake and disposition for -D-003, review this conditional D-004 protocol, and resolve any protocol defects -before freezing an evidence epoch. Running the suite then produces decision -evidence; it still does not implement S3b. +D-003, review this conditional D-004 protocol, and close or revise FR-01 through +FR-09 before freezing an evidence epoch. Only the resulting frozen suite may +produce decision evidence; it still does not implement S3b. Until those actions occur, D-004 remains proposed, the architecture role map remains a recommendation, the S3a Typed Reference Core remains the only