Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ from cp-ast-ecosystems share links.

```sh
rt 'https://manabeai.github.io/cp-ast-ecosystems/?state=...'
rt 'v2....' --seed 42
rt browse
rt '%7B%22schema_version%22%3A1%2C...%7D' --seed 42
rt open
rt state.txt
```

The generator accepts both the current compressed `v2.` share state and legacy
base64 state values.
The generator accepts URL-encoded JSON `state` values.
23 changes: 22 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod browse;
pub mod link_state;

use cp_ast_core::sample::{generate, sample_to_text};
use std::path::Path;
use thiserror::Error;

pub const EDITOR_URL: &str = "https://manabeai.github.io/cp-ast-ecosystems/";
Expand All @@ -15,12 +16,32 @@ pub enum RtError {
Ast(#[from] ast_dto::AstDtoError),
#[error("failed to generate sample: {0}")]
Sample(#[from] cp_ast_core::sample::GenerationError),
#[error("failed to read input file {path}: {source}")]
ReadInputFile { path: String, source: std::io::Error },
}

pub fn generate_sample_text(input: &str, seed: Option<u64>) -> Result<(u64, String), RtError> {
let json = link_state::decode_input(input)?;
let resolved_input = resolve_input(input)?;
let json = link_state::decode_input(&resolved_input)?;
let engine = ast_dto::engine_from_json(&json)?;
let seed = seed.unwrap_or_else(rand::random::<u64>);
let sample = generate(&engine, seed)?;
Ok((seed, sample_to_text(&engine, &sample)))
}

fn resolve_input(input: &str) -> Result<String, RtError> {
let candidate = input.trim();
if candidate.is_empty() {
return Ok(String::new());
}
let path = Path::new(candidate);
if path.is_file() {
let contents = std::fs::read_to_string(path).map_err(|source| RtError::ReadInputFile {
path: candidate.to_owned(),
source,
})?;
Ok(contents.trim().to_owned())
} else {
Ok(candidate.to_owned())
}
}
55 changes: 9 additions & 46 deletions src/link_state.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
use std::io::Read;

use base64::Engine;
use flate2::read::GzDecoder;
use thiserror::Error;
use url::Url;

const SHARE_STATE_PREFIX: &str = "v2.";

#[derive(Debug, Error)]
pub enum LinkStateError {
#[error("input does not contain a state value")]
MissingState,
#[error("invalid URL encoding: {0}")]
UrlDecode(String),
#[error("invalid base64 state: {0}")]
Base64(#[from] base64::DecodeError),
#[error("failed to decompress state: {0}")]
Gzip(#[from] std::io::Error),
#[error("state is not valid UTF-8: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
}

pub fn decode_input(input: &str) -> Result<String, LinkStateError> {
Expand Down Expand Up @@ -55,58 +43,33 @@ pub fn extract_state(input: &str) -> Result<String, LinkStateError> {
}

pub fn decode_state(state: &str) -> Result<String, LinkStateError> {
if let Some(encoded) = state.strip_prefix(SHARE_STATE_PREFIX) {
let compressed = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded)?;
let mut decoder = GzDecoder::new(compressed.as_slice());
let mut json = String::new();
decoder.read_to_string(&mut json)?;
return Ok(json);
}

let decoded =
urlencoding::decode(state).map_err(|err| LinkStateError::UrlDecode(err.to_string()))?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decode URL state exactly once

When the input is a full URL, extract_state already returns a decoded query value via url.query_pairs(), but decode_state unconditionally applies urlencoding::decode again. This double-decoding breaks valid states that contain literal percent sequences: for example %25 in JSON becomes % after query parsing and then either errors (invalid URL encoding) or is transformed again (e.g. %2F -> /). As a result, some valid share links fail only in URL form while the same state string works directly.

Useful? React with 👍 / 👎.

let bytes = base64::engine::general_purpose::STANDARD.decode(decoded.as_bytes())?;
Ok(String::from_utf8(bytes)?)
Ok(decoded.into_owned())
}

#[cfg(test)]
mod tests {
use super::*;
use flate2::{Compression, write::GzEncoder};
use std::io::Write;

fn encode_v2(json: &str) -> String {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(json.as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
format!(
"{SHARE_STATE_PREFIX}{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(compressed)
)
}

#[test]
fn extracts_state_from_full_url() {
let input = "https://manabeai.github.io/cp-ast-ecosystems/?state=v2.abc";
assert_eq!(extract_state(input).unwrap(), "v2.abc");
let input = "https://manabeai.github.io/cp-ast-ecosystems/?state=%7B%22schema_version%22%3A1%7D";
assert_eq!(
extract_state(input).unwrap(),
r#"{"schema_version":1}"#
);
}

#[test]
fn accepts_state_value_directly() {
assert_eq!(extract_state("v2.abc").unwrap(), "v2.abc");
}

#[test]
fn decodes_v2_gzip_base64url_state() {
let state = encode_v2(r#"{"schema_version":1}"#);
assert_eq!(decode_state(&state).unwrap(), r#"{"schema_version":1}"#);
assert_eq!(extract_state("abc").unwrap(), "abc");
}

#[test]
fn decodes_legacy_url_encoded_base64_state() {
fn decodes_url_encoded_json_state() {
let json = r#"{"schema_version":1}"#;
let encoded = base64::engine::general_purpose::STANDARD.encode(json);
let state = urlencoding::encode(&encoded);
let state = urlencoding::encode(json);
assert_eq!(decode_state(&state).unwrap(), json);
}
}
7 changes: 4 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use random_test_cli::{EDITOR_URL, browse, generate_sample_text};
#[command(
name = "rt",
version,
about = "Generate random tests from cp-ast share links"
about = "Generate random tests from cp-ast share links",
arg_required_else_help = true
)]
struct Cli {
#[command(subcommand)]
Expand All @@ -22,7 +23,7 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// Open the cp-ast editor in the default browser.
Browse,
Open,
}

fn main() {
Expand All @@ -34,7 +35,7 @@ fn main() {

fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
Some(Command::Browse) => {
Some(Command::Open) => {
browse::open_url(EDITOR_URL)?;
}
None => {
Expand Down
32 changes: 25 additions & 7 deletions tests/cli.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use base64::Engine;
use std::process::Command;
use std::{fs, process::Command};

fn minimal_scalar_json() -> &'static str {
r#"{
Expand All @@ -26,15 +25,14 @@ fn minimal_scalar_json() -> &'static str {
}"#
}

fn legacy_state() -> String {
urlencoding::encode(&base64::engine::general_purpose::STANDARD.encode(minimal_scalar_json()))
.into_owned()
fn encoded_state() -> String {
urlencoding::encode(minimal_scalar_json()).into_owned()
}

#[test]
fn same_seed_generates_same_output() {
let bin = env!("CARGO_BIN_EXE_rt");
let state = legacy_state();
let state = encoded_state();
let first = Command::new(bin)
.arg(&state)
.arg("--seed")
Expand Down Expand Up @@ -67,7 +65,7 @@ fn full_url_input_is_accepted() {
let bin = env!("CARGO_BIN_EXE_rt");
let url = format!(
"https://manabeai.github.io/cp-ast-ecosystems/?state={}",
legacy_state()
encoded_state()
);
let output = Command::new(bin)
.arg(url)
Expand All @@ -83,3 +81,23 @@ fn full_url_input_is_accepted() {
);
assert!(!output.stdout.is_empty());
}

#[test]
fn input_file_is_accepted() {
let bin = env!("CARGO_BIN_EXE_rt");
let path = std::env::temp_dir().join("rt_state.txt");
fs::write(&path, encoded_state()).expect("state file should be written");
let output = Command::new(bin)
.arg(&path)
.arg("--seed")
.arg("1")
.output()
.expect("rt should run");

assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!output.stdout.is_empty());
}