├── rustfmt.toml ├── .gitattributes ├── src ├── lib.rs ├── lcs │ └── mod.rs ├── main.rs └── diff │ └── mod.rs ├── .gitignore ├── Cargo.toml ├── tests ├── weather1.json ├── e2e.test └── vendor │ └── wapp │ └── wapp.tcl ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── justfile ├── README.md └── Cargo.lock /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /Cargo.lock -diff 2 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod diff; 2 | pub mod lcs; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /attic 2 | /dist 3 | /target 4 | 5 | *.swp 6 | 7 | /help 8 | /jsonwatch* 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jsonwatch" 3 | version = "0.11.0" 4 | authors = ["D. Bohdan "] 5 | edition = "2018" 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/dbohdan/jsonwatch" 9 | homepage = "https://github.com/dbohdan/jsonwatch" 10 | description = "Track changes in JSON data from the command line" 11 | keywords = ["json"] 12 | categories = ["command-line-utilities"] 13 | 14 | [dependencies] 15 | chrono = "~0.4" 16 | clap = {features = ["derive"], version = "~4.5"} 17 | clap_complete = "4.5.58" 18 | serde_json = {features = ["preserve_order"], version = "~1.0"} 19 | ureq = "~3.1" 20 | -------------------------------------------------------------------------------- /tests/weather1.json: -------------------------------------------------------------------------------- 1 | {"clouds": {"all": 92}, "name": "Kiev", "coord": { 2 | "lat": 50.43, "lon": 30.52}, "sys": {"country": "UA", 3 | "message": 0.0051, "sunset": 1394985874, "sunrise": 1394942901 4 | }, "weather": [{"main": "Snow", "id": 612, "icon": "13d", 5 | "description": "light shower sleet"}, {"main": "Rain", "id": 6 | 520, "icon": "09d", "description": "light intensity shower rain"}], 7 | "rain": {"3h": 2}, "base": "cmc stations", "dt": 8 | 1394979003, "main": {"pressure": 974.8229, "humidity": 91, 9 | "temp_max": 277.45, "temp": 276.45, "temp_min": 276.15}, "id" 10 | : 703448, "wind": {"speed": 10.27, "deg": 245.507}, "cod": 11 | 200} 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: 10 | - macos-14 11 | - ubuntu-latest 12 | - windows-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Install dependencies with APT 18 | if: startsWith(matrix.os, 'ubuntu-') 19 | run: | 20 | sudo apt-get install -y expect 21 | 22 | - name: Set up MacPorts 23 | if: startsWith(matrix.os, 'macos-') 24 | uses: melusina-org/setup-macports@v1 25 | 26 | - name: Install Expect for Tcl 8.6 27 | if: startsWith(matrix.os, 'macos-') 28 | run: | 29 | port install expect 30 | 31 | - name: Set up just 32 | uses: extractions/setup-just@v2 33 | 34 | - name: Run tests 35 | run: | 36 | just test 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2015, 2017-2018, 2020, 2023-2025 D. Bohdan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 2 | 3 | cargo-toml := read("Cargo.toml") 4 | version := replace_regex(cargo-toml, '(?ms).*^version = "([^"]+)".*', "$1") 5 | release-dir := "dist" / version 6 | linux-binary := "jsonwatch-v" + version + "-linux-x86_64" 7 | win32-binary := "jsonwatch-v" + version + "-win32.exe" 8 | checksum-file := "SHA256SUMS.txt" 9 | ssh-key := x"~/.ssh/git" 10 | tclsh := "tclsh" 11 | export JSONWATCH_COMMAND := "target/debug/jsonwatch" 12 | 13 | default: test 14 | 15 | version: 16 | @echo {{ version }} 17 | 18 | build: 19 | cargo build 20 | 21 | [unix] 22 | release: release-linux release-windows 23 | #! /bin/sh 24 | cd {{ release-dir }} 25 | sha256sum {{ linux-binary }} {{ win32-binary }} > {{ checksum-file }} 26 | ssh-keygen -Y sign -n file -f {{ ssh-key }} {{ checksum-file }} 27 | 28 | [unix] 29 | release-linux: 30 | mkdir -p {{ release-dir }} 31 | cargo build --release --target x86_64-unknown-linux-musl 32 | cp target/x86_64-unknown-linux-musl/release/jsonwatch {{ release-dir / linux-binary }} 33 | strip {{ release-dir / linux-binary }} 34 | 35 | [unix] 36 | release-windows: 37 | mkdir -p {{ release-dir }} 38 | cargo build --release --target i686-pc-windows-gnu 39 | cp target/i686-pc-windows-gnu/release/jsonwatch.exe {{ release-dir / win32-binary }} 40 | strip {{ release-dir / win32-binary }} 41 | 42 | [unix] 43 | test: build test-unit test-e2e 44 | 45 | [windows] 46 | test: build test-unit 47 | 48 | # The end-to-end tests use Expect and do not work on Windows. 49 | [unix] 50 | test-e2e: 51 | {{ tclsh }} tests/e2e.test 52 | 53 | test-unit: 54 | cargo test 55 | -------------------------------------------------------------------------------- /src/lcs/mod.rs: -------------------------------------------------------------------------------- 1 | // An implementation of the dynamic programming algorithm for solving the 2 | // longest common subsequence (LCS) problem. 3 | 4 | use std::ops::{Deref, DerefMut}; 5 | 6 | #[derive(Debug)] 7 | pub struct Lengths(Vec); 8 | 9 | impl Deref for Lengths { 10 | type Target = Vec; 11 | 12 | fn deref(&self) -> &Self::Target { 13 | &self.0 14 | } 15 | } 16 | 17 | impl DerefMut for Lengths { 18 | fn deref_mut(&mut self) -> &mut Self::Target { 19 | &mut self.0 20 | } 21 | } 22 | 23 | impl Lengths { 24 | pub fn new(a: &Vec, b: &Vec) -> Lengths 25 | where 26 | T: PartialEq, 27 | { 28 | let w = a.len() + 1; 29 | let h = b.len() + 1; 30 | 31 | // v[i, j] => v[w * j + i] 32 | let mut v = Vec::new(); 33 | v.resize(w * h, 0); 34 | let mut m = Lengths(v); 35 | 36 | for j in 1..h { 37 | for i in 1..w { 38 | m[w * j + i] = if a[i - 1] == b[j - 1] { 39 | 1 + m[w * (j - 1) + i - 1] 40 | } else { 41 | let left = m[w * j + i - 1]; 42 | let up = m[w * (j - 1) + i]; 43 | if left > up { 44 | left 45 | } else { 46 | up 47 | } 48 | }; 49 | } 50 | } 51 | 52 | m 53 | } 54 | 55 | pub fn backtrack( 56 | &self, 57 | a: &Vec, 58 | b: &Vec, 59 | i: usize, 60 | j: usize, 61 | ) -> Vec<(usize, usize)> 62 | where 63 | T: PartialEq, 64 | { 65 | if i == 0 || j == 0 { 66 | return vec![]; 67 | } 68 | 69 | if a[i - 1] == b[j - 1] { 70 | let mut bt = self.backtrack(a, b, i - 1, j - 1); 71 | bt.push((i - 1, j - 1)); 72 | return bt; 73 | } 74 | 75 | let w = a.len() + 1; 76 | if self[w * (j - 1) + i] > self[w * j + i - 1] { 77 | return self.backtrack(a, b, i, j - 1); 78 | } 79 | 80 | self.backtrack(a, b, i - 1, j) 81 | } 82 | } 83 | 84 | pub fn lcs(a: &Vec, b: &Vec) -> Vec<(usize, usize)> 85 | where 86 | T: PartialEq, 87 | { 88 | let m = Lengths::new(a, b); 89 | m.backtrack(a, b, a.len(), b.len()) 90 | } 91 | 92 | pub fn pick(a: &Vec, idx: Vec<(usize, usize)>) -> Vec 93 | where 94 | T: Clone, 95 | { 96 | let mut res: Vec = vec![]; 97 | for (i, _) in idx { 98 | res.push(a[i].clone()); 99 | } 100 | 101 | res 102 | } 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use super::*; 107 | 108 | #[test] 109 | fn test_lengths_1() { 110 | let m = Lengths::new(&vec![1, 2], &vec![1, 2]); 111 | assert_eq!(m.0, vec![0, 0, 0, 0, 1, 1, 0, 1, 2]); 112 | } 113 | 114 | #[test] 115 | fn test_lengths_2() { 116 | let m = Lengths::new(&vec![1, 3, 5], &vec![1, 2, 3, 4, 5]); 117 | assert_eq!( 118 | m.0, 119 | vec![ 120 | 0, 0, 0, 0, 121 | 0, 1, 1, 1, 122 | 0, 1, 1, 1, 123 | 0, 1, 2, 2, 124 | 0, 1, 2, 2, 125 | 0, 1, 2, 3 126 | ] 127 | ); 128 | } 129 | 130 | #[test] 131 | fn test_lengths_3() { 132 | let m = Lengths::new(&vec![1, 2, 3, 4, 5], &vec![1, 3, 5]); 133 | assert_eq!( 134 | m.0, 135 | vec![ 136 | 0, 0, 0, 0, 0, 0, 137 | 0, 1, 1, 1, 1, 1, 138 | 0, 1, 1, 2, 2, 2, 139 | 0, 1, 1, 2, 2, 3 140 | ] 141 | ); 142 | } 143 | 144 | #[test] 145 | fn test_backtrack_1() { 146 | let a = &vec![1, 3, 5, 7]; 147 | let b = &vec![5, 3, 7]; 148 | let m = Lengths::new(a, b); 149 | let idx = m.backtrack(a, b, a.len(), b.len()); 150 | assert_eq!(idx, vec![(1, 1), (3, 2)]); 151 | } 152 | 153 | #[test] 154 | fn test_lcs_1() { 155 | let a = vec!['a', 'b', 'c']; 156 | let b = vec!['a', 'b', 'c']; 157 | assert_eq!(lcs(&a, &b), vec![(0, 0), (1, 1), (2, 2)]); 158 | } 159 | 160 | #[test] 161 | fn test_lcs_2() { 162 | let a = vec!['1', 'h', 'e', 'l', 'l', 'o', '3', '0']; 163 | let b = vec!['2', 'w', 'o', 'r', 'l', 'd', '4', '5', '6', '0']; 164 | assert_eq!(lcs(&a, &b), vec![(3, 4), (7, 9)]); 165 | } 166 | 167 | #[test] 168 | fn test_lcs_3() { 169 | let a = vec![1.0, 2.0, 3.0]; 170 | let b = vec![5.0]; 171 | assert_eq!(lcs(&a, &b), vec![]); 172 | } 173 | 174 | #[test] 175 | fn test_lcs_4() { 176 | let a = vec![1, 2, 3]; 177 | let b = vec![]; 178 | assert_eq!(lcs(&a, &b), vec![]); 179 | } 180 | 181 | #[test] 182 | fn test_lcs_5() { 183 | let a = vec![]; 184 | let b = vec![1, 2, 3]; 185 | assert_eq!(lcs(&a, &b), vec![]); 186 | } 187 | 188 | #[test] 189 | fn test_lcs_6() { 190 | let a: Vec = vec![]; 191 | let b: Vec = vec![]; 192 | assert_eq!(lcs(&a, &b), vec![]); 193 | } 194 | 195 | #[test] 196 | fn test_pick_1() { 197 | let a = vec![0, 1, 99, 8, 101]; 198 | assert_eq!(pick(&a, vec![(1, 0), (0, 0), (3, 0)]), vec![1, 0, 8]); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use clap::{CommandFactory, Parser, Subcommand}; 3 | use clap_complete::Shell; 4 | use jsonwatch::diff; 5 | use std::{error::Error, fmt::Write, process::Command, str, thread, time}; 6 | 7 | #[derive(Parser, Debug)] 8 | #[command( 9 | name = "jsonwatch", 10 | about = "Track changes in JSON data", 11 | version = "0.11.0" 12 | )] 13 | struct Cli { 14 | /// Don't print date and time for each diff 15 | #[arg(short = 'D', long)] 16 | no_date: bool, 17 | 18 | /// Don't print initial JSON values 19 | #[arg(short = 'I', long)] 20 | no_initial_values: bool, 21 | 22 | /// Exit after a number of changes 23 | #[arg(short = 'c', long = "changes", value_name = "count")] 24 | changes: Option, 25 | 26 | /// Polling interval in seconds 27 | #[arg(short = 'n', long, value_name = "seconds", default_value = "2")] 28 | interval: u32, 29 | 30 | /// Verbose mode ('-v' for errors, '-vv' for errors and input data) 31 | #[arg(short, long, action = clap::ArgAction::Count)] 32 | verbose: u8, 33 | 34 | /// Subcommands for different data sources 35 | #[command(subcommand)] 36 | command: Commands, 37 | } 38 | 39 | #[derive(Subcommand, Debug)] 40 | enum Commands { 41 | /// Execute a command and track changes in the JSON output 42 | #[command(aliases(["command"]))] 43 | Cmd { 44 | /// Command to execute 45 | #[arg(value_name = "command")] 46 | command: String, 47 | 48 | /// Arguments to the command 49 | #[arg( 50 | value_name = "arg", 51 | trailing_var_arg = true, 52 | allow_hyphen_values = true 53 | )] 54 | args: Vec, 55 | }, 56 | 57 | /// Fetch a URL and track changes in the JSON data 58 | #[command()] 59 | Url { 60 | /// URL to fetch 61 | #[arg(value_name = "url")] 62 | url: String, 63 | 64 | /// Custom user-agent string 65 | #[arg( 66 | short = 'A', 67 | long = "user-agent", 68 | value_name = "user-agent", 69 | default_value = "curl/7.58.0" 70 | )] 71 | user_agent: String, 72 | 73 | /// Custom headers in the format "X-Foo: bar" 74 | #[arg( 75 | short = 'H', 76 | long = "header", 77 | value_name = "header", 78 | action = clap::ArgAction::Append 79 | )] 80 | headers: Vec, 81 | }, 82 | 83 | /// Generate shell completions 84 | #[command()] 85 | Init { 86 | /// The shell to generate completions for 87 | #[arg(value_enum, value_name = "shell")] 88 | shell: Shell, 89 | }, 90 | } 91 | 92 | const MAX_BODY_SIZE: u64 = 128 * 1024 * 1024; 93 | const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z"; 94 | 95 | fn run_command( 96 | command: &String, 97 | args: &[String], 98 | ) -> Result> { 99 | if command.is_empty() { 100 | return Ok(String::new()); 101 | } 102 | 103 | let output = Command::new(command).args(args).output()?; 104 | 105 | Ok(String::from_utf8_lossy(&output.stdout).into_owned()) 106 | } 107 | 108 | fn fetch_url( 109 | url: &str, 110 | user_agent: &str, 111 | headers: &[String], 112 | ) -> Result> { 113 | let mut request = ureq::get(url).header("User-Agent", user_agent); 114 | 115 | for header in headers { 116 | if let Some((name, value)) = header.split_once(':') { 117 | request = request.header(name.trim(), value.trim()); 118 | } 119 | } 120 | 121 | Ok(request 122 | .call()? 123 | .body_mut() 124 | .with_config() 125 | .limit(MAX_BODY_SIZE) 126 | .read_to_string()?) 127 | } 128 | 129 | pub fn escape_for_terminal(input: &str) -> String { 130 | let mut result = String::with_capacity(input.len()); 131 | 132 | for ch in input.chars() { 133 | match ch { 134 | // Allow newline and tab for formatting. 135 | '\n' | '\t' => result.push(ch), 136 | 137 | // Escape other control characters. 138 | ch if ch.is_control() => { 139 | write!(&mut result, "\\u{{{:x}}}", ch as u32).unwrap(); 140 | } 141 | 142 | // Keep all other characters. 143 | _ => result.push(ch), 144 | } 145 | } 146 | 147 | result 148 | } 149 | 150 | fn print_debug(input_data: &str) { 151 | let local = Local::now(); 152 | let timestamp = local.format(&TIMESTAMP_FORMAT); 153 | 154 | let multiline = 155 | input_data.trim_end().contains('\n') || input_data.ends_with("\n\n"); 156 | let escaped = escape_for_terminal(&input_data); 157 | 158 | if multiline { 159 | eprint!("[DEBUG {}] Multiline input data:\n{}", timestamp, escaped); 160 | } else { 161 | eprint!("[DEBUG {}] Input data: {}", timestamp, escaped); 162 | } 163 | 164 | if !input_data.is_empty() && !input_data.ends_with('\n') { 165 | eprintln!(); 166 | } 167 | if multiline { 168 | eprintln!("[DEBUG {}] End of multiline input data", timestamp); 169 | } 170 | } 171 | 172 | fn watch( 173 | interval: time::Duration, 174 | changes: Option, 175 | print_date: bool, 176 | print_initial: bool, 177 | verbose: u8, 178 | lambda: impl Fn() -> Result>, 179 | ) { 180 | let mut change_count = 0; 181 | let input_data = match lambda() { 182 | Ok(s) => s, 183 | Err(e) => { 184 | if verbose >= 1 { 185 | let local = Local::now(); 186 | let timestamp = local.format(&TIMESTAMP_FORMAT); 187 | eprintln!("[ERROR {}] {}", timestamp, e); 188 | } 189 | 190 | String::new() 191 | } 192 | }; 193 | let mut data: Option = 194 | match serde_json::from_str(&input_data) { 195 | Ok(json) => Some(json), 196 | Err(e) => { 197 | if verbose >= 1 { 198 | let local = Local::now(); 199 | let timestamp = local.format(&TIMESTAMP_FORMAT); 200 | if input_data.trim().is_empty() { 201 | eprintln!("[ERROR {}] Blank response", timestamp); 202 | } else { 203 | eprintln!( 204 | "[ERROR {}] JSON parsing error: {}", 205 | timestamp, e 206 | ); 207 | } 208 | } 209 | 210 | None 211 | } 212 | }; 213 | 214 | if print_initial { 215 | if verbose >= 2 { 216 | print_debug(&input_data); 217 | } 218 | 219 | if let Some(json) = &data { 220 | println!("{}", serde_json::to_string_pretty(&json).unwrap()) 221 | } 222 | } 223 | 224 | loop { 225 | if let Some(max) = changes { 226 | if change_count >= max { 227 | break; 228 | } 229 | } 230 | 231 | thread::sleep(interval); 232 | 233 | let input_data = match lambda() { 234 | Ok(s) => s, 235 | Err(e) => { 236 | if verbose >= 1 { 237 | let local = Local::now(); 238 | let timestamp = local.format(&TIMESTAMP_FORMAT); 239 | eprintln!("[ERROR {}] {}", timestamp, e); 240 | } 241 | 242 | continue; 243 | } 244 | }; 245 | if verbose >= 2 { 246 | print_debug(&input_data); 247 | } 248 | 249 | let prev = data.clone(); 250 | data = match serde_json::from_str(&input_data) { 251 | Ok(json) => Some(json), 252 | Err(e) => { 253 | if verbose >= 1 { 254 | let local = Local::now(); 255 | let timestamp = local.format(&TIMESTAMP_FORMAT); 256 | if input_data.trim().is_empty() { 257 | eprintln!("[ERROR {}] Blank response", timestamp); 258 | } else { 259 | eprintln!( 260 | "[ERROR {}] JSON parsing error: {}", 261 | timestamp, e 262 | ); 263 | } 264 | } 265 | continue; 266 | } 267 | }; 268 | 269 | let diff = diff::diff(&prev, &data); 270 | 271 | let changed = diff.len(); 272 | if changed == 0 { 273 | continue; 274 | } 275 | 276 | change_count += 1; 277 | 278 | if print_date { 279 | let local = Local::now(); 280 | print!("{}", local.format(&TIMESTAMP_FORMAT)); 281 | 282 | if changed == 1 { 283 | print!(" "); 284 | } else { 285 | println!(); 286 | } 287 | } 288 | 289 | if changed == 1 { 290 | print!("{}", diff); 291 | } else { 292 | let s = format!("{}", diff) 293 | .lines() 294 | .collect::>() 295 | .join("\n "); 296 | println!(" {}", s); 297 | } 298 | } 299 | } 300 | 301 | fn main() { 302 | let cli = Cli::parse(); 303 | 304 | if let Commands::Init { shell } = cli.command { 305 | let mut cmd = Cli::command(); 306 | clap_complete::generate( 307 | shell, 308 | &mut cmd, 309 | "jsonwatch", 310 | &mut std::io::stdout(), 311 | ); 312 | return; 313 | } 314 | 315 | let lambda: Box Result>> = 316 | match &cli.command { 317 | Commands::Init { .. } => unreachable!(), 318 | 319 | Commands::Cmd { args, command } => { 320 | let args = args.clone(); 321 | let command = command.clone(); 322 | Box::new(move || run_command(&command, &args)) 323 | } 324 | 325 | Commands::Url { 326 | url, 327 | user_agent, 328 | headers, 329 | } => { 330 | let url = url.clone(); 331 | let user_agent = user_agent.clone(); 332 | let headers = headers.clone(); 333 | Box::new(move || fetch_url(&url, &user_agent, &headers)) 334 | } 335 | }; 336 | 337 | watch( 338 | time::Duration::from_secs(cli.interval as u64), 339 | cli.changes, 340 | !cli.no_date, 341 | !cli.no_initial_values, 342 | cli.verbose, 343 | lambda, 344 | ); 345 | } 346 | -------------------------------------------------------------------------------- /tests/e2e.test: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env tclsh 2 | # End-to-end tests for jsonwatch. 3 | # ============================================================================== 4 | # Copyright (c) 2020, 2023-2025 D. Bohdan and contributors listed in AUTHORS 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | # ============================================================================== 24 | 25 | package require Tcl 8.6 26 | package require Expect 5 27 | package require tcltest 2 28 | 29 | source [file dirname [info script]]/vendor/wapp/wapp.tcl 30 | 31 | # Expect settings. 32 | set max_match 100000 33 | set timeout 5 34 | 35 | # Our own settings. 36 | set env_var JSONWATCH_COMMAND 37 | set host 127.0.0.1 38 | set port 8015 39 | 40 | 41 | if {![info exists ::env($env_var)]} { 42 | puts stderr [list environment variable $env_var must be set] 43 | exit 2 44 | } 45 | 46 | set binary $::env($env_var) 47 | 48 | if {![file exists $binary]} { 49 | puts stderr [list file $binary from environment variable $env_var doesn't exist] 50 | exit 2 51 | } 52 | 53 | 54 | # CLI smoke tests. 55 | 56 | tcltest::test cli-1.1 {} -body { 57 | exec $binary -h 58 | } -match regexp -result ***:(?i)usage 59 | 60 | 61 | tcltest::test cli-1.2 {} -body { 62 | exec $binary --nonsense 63 | } -returnCodes error -match glob -result * 64 | 65 | 66 | tcltest::test cli-1.3 {} -body { 67 | exec $binary url http://example.com command false 68 | } -returnCodes error -match glob -result * 69 | 70 | 71 | # Shell completions. 72 | 73 | tcltest::test cli-2.1 {init subcommand - bash} -body { 74 | exec $binary init bash 75 | } -match glob -result *jsonwatch* 76 | 77 | 78 | tcltest::test cli-2.2 {init subcommand - elvish} -body { 79 | exec $binary init elvish 80 | } -match glob -result *jsonwatch* 81 | 82 | 83 | tcltest::test cli-2.3 {init subcommand - fish} -body { 84 | exec $binary init fish 85 | } -match glob -result *jsonwatch* 86 | 87 | 88 | tcltest::test cli-2.4 {init subcommand - powershell} -body { 89 | exec $binary init powershell 90 | } -match glob -result *jsonwatch* 91 | 92 | 93 | tcltest::test cli-2.5 {init subcommand - zsh} -body { 94 | exec $binary init zsh 95 | } -match glob -result *jsonwatch* 96 | 97 | 98 | # Command tests. 99 | 100 | tcltest::test command-1.1 {} -body { 101 | spawn $binary command cat tests/weather1.json 102 | expect { 103 | -regexp {"description":\s*"light shower sleet"} { return matched } 104 | timeout { return {timed out} } 105 | } 106 | } -cleanup close -result matched 107 | 108 | 109 | tcltest::test command-1.2 {} -setup {set timeout 2} -body { 110 | spawn $binary -n 1 --no-initial-values command cat tests/weather1.json 111 | expect { 112 | -regexp {[A-Za-z0-9]} { return text } 113 | timeout { return {timed out} } 114 | } 115 | } -cleanup {close; set timeout 5} -result {timed out} 116 | 117 | 118 | tcltest::test command-1.3 {} -body { 119 | set ch [file tempfile path] 120 | puts $ch {[1,2,3,4,5]} 121 | flush $ch 122 | 123 | spawn $binary -n 1 command sh -c "cat '$path'" 124 | expect { 125 | -glob *1*2*3*4*5* {} 126 | timeout { return {first timeout} } 127 | } 128 | 129 | seek $ch 0 130 | puts $ch {[1,2,3,4,5,6,7]} 131 | flush $ch 132 | 133 | expect \ 134 | -glob *[clock format [clock seconds] -format %Y-%m]*6*7* {} \ 135 | timeout { return {second timeout} } \ 136 | 137 | close $ch 138 | 139 | lindex completed 140 | } -cleanup {close; file delete $path} -result completed 141 | 142 | 143 | ### URL tests. 144 | 145 | proc wapp-page-timestamp {} { 146 | wapp-mimetype application/json 147 | wapp-subst {{"timestamp": %string([clock seconds])}} 148 | } 149 | 150 | 151 | set count 0 152 | proc wapp-page-alternate {} { 153 | wapp-mimetype application/json 154 | 155 | if {$::count % 2 == 1} { 156 | wapp {["foo", "baz"]} 157 | } else { 158 | wapp {["bar"]} 159 | } 160 | 161 | incr ::count 162 | } 163 | 164 | 165 | proc wapp-page-nested-obj {} { 166 | wapp-mimetype application/json 167 | 168 | if {$::count % 2 == 1} { 169 | wapp {{"k": "v"}} 170 | } else { 171 | wapp {{"k": {"nested": "v2"}}} 172 | } 173 | 174 | incr ::count 175 | } 176 | 177 | 178 | proc wapp-page-headers {} { 179 | wapp-mimetype application/json 180 | 181 | wapp [format \ 182 | {{"X-Bar": "%s", "X-Foo": "%s"}} \ 183 | [wapp-param .hdr:X-BAR] \ 184 | [wapp-param .hdr:X-FOO] \ 185 | ] 186 | } 187 | 188 | 189 | wapp-start [list -fromip 127.0.0.1 -nowait -server $port -trace] 190 | 191 | 192 | tcltest::test url-1.1 {} -body { 193 | spawn $binary --interval 1 url http://$host:$port/timestamp 194 | set re {{\s*"timestamp":\s*\d+\s*}} 195 | expect \ 196 | -regexp $re { lindex matched } \ 197 | timeout { lindex {first timeout} } \ 198 | ; 199 | 200 | set re2 [format {%1$s[\dT:+-]+ .timestamp: \d+ -> \d+} \ 201 | [clock format [clock seconds] -format %Y-%m] \ 202 | ] 203 | expect \ 204 | -regexp $re2 { lindex matched } \ 205 | timeout { lindex {second timeout} } \ 206 | ; 207 | 208 | } -cleanup close -result matched 209 | 210 | 211 | tcltest::test url-1.2 {} -body { 212 | spawn $binary -n 1 --no-date url http://$host:$port/timestamp 213 | 214 | expect { 215 | -regexp {\n.timestamp: \d+ -> \d+} { lindex matched } 216 | timeout { lindex {timed out} } 217 | } 218 | } -cleanup close -result matched 219 | 220 | 221 | tcltest::test url-1.3 {} -body { 222 | spawn $binary -I -n 1 url http://$host:$port/alternate 223 | 224 | expect { 225 | -regexp { ?\.0: "?bar"? -> "?foo"?\s+ ?\+ .1: "?baz"?} { 226 | lindex matched 227 | } 228 | timeout { 229 | lindex {timed out} 230 | } 231 | } 232 | } -cleanup close -result matched 233 | 234 | 235 | tcltest::test url-1.4 {} -body { 236 | spawn $binary -n 1 --no-initial-values url http://$host:$port/nested-obj 237 | 238 | expect { 239 | -regexp { ?- \.k\.nested: "v2"\s+ \+ \.k: "v"} { 240 | lindex matched 241 | } 242 | timeout { 243 | lindex {timed out} 244 | } 245 | } 246 | } -cleanup close -result matched 247 | 248 | 249 | tcltest::test url-1.5 {} -body { 250 | spawn $binary \ 251 | --interval 1 \ 252 | url \ 253 | -H {X-Foo: This is foo. } \ 254 | --header X-Bar:this-is-bar \ 255 | http://$host:$port/headers \ 256 | ; 257 | 258 | expect { 259 | -regexp {this-is-bar.*This is foo\.\"} { 260 | lindex matched 261 | } 262 | timeout { 263 | lindex {timed out} 264 | } 265 | } 266 | } -cleanup close -result matched 267 | 268 | 269 | # Error handling. 270 | 271 | set count_403 0 272 | proc wapp-page-json-403-json {} { 273 | global count_403 274 | wapp-mimetype application/json 275 | 276 | if {$count_403 % 2 == 1} { 277 | wapp-reply-code {403 Forbidden} 278 | wapp Forbidden 279 | } else { 280 | wapp "{\"counter\": $count_403}" 281 | } 282 | 283 | incr count_403 284 | } 285 | 286 | 287 | set count_garbage 0 288 | proc wapp-page-json-garbage-json {} { 289 | global count_garbage 290 | wapp-mimetype application/json 291 | 292 | if {$count_garbage % 2 == 1} { 293 | wapp {not json} 294 | } else { 295 | wapp "{\"counter\": $count_garbage}" 296 | } 297 | 298 | incr count_garbage 299 | } 300 | 301 | 302 | set count_blank 0 303 | proc wapp-page-json-blank-json {} { 304 | global count_blank 305 | wapp-mimetype application/json 306 | 307 | if {$count_blank % 2 == 1} { 308 | wapp {} 309 | } else { 310 | wapp "{\"counter\": $count_blank}" 311 | } 312 | 313 | incr count_blank 314 | } 315 | 316 | 317 | tcltest::test error-1.1 {HTTP error} -setup {set timeout 5} -body { 318 | # JSON -> 403 -> JSON. 319 | # Should result in one change. 320 | spawn $binary -v -n 1 --no-initial-values url http://$host:$port/json-403-json 321 | 322 | expect { 323 | -glob {.counter: 0 -> 2} { return matched } 324 | timeout { return {timed out} } 325 | } 326 | } -cleanup {close; set timeout 5} -result matched 327 | 328 | 329 | tcltest::test error-1.2 {garbage JSON} -setup {set timeout 5} -body { 330 | # JSON -> garbage -> JSON. 331 | # Should result in one change. 332 | spawn $binary -v -n 1 --no-initial-values url http://$host:$port/json-garbage-json 333 | 334 | expect { 335 | -glob {.counter: 0 -> 2} { return matched } 336 | timeout { return {timed out} } 337 | } 338 | } -cleanup {close; set timeout 5} -result matched 339 | 340 | 341 | tcltest::test error-1.3 {blank response} -setup {set timeout 5} -body { 342 | # JSON -> blank -> JSON. 343 | # Should result in one change. 344 | spawn $binary -v -n 1 --no-initial-values url http://$host:$port/json-blank-json 345 | 346 | expect { 347 | -glob {.counter: 0 -> 2} { return matched } 348 | timeout { return {timed out} } 349 | } 350 | } -cleanup {close; set timeout 5} -result matched 351 | 352 | 353 | # Extra options. 354 | 355 | tcltest::test changes-1.1 {} -body { 356 | spawn $binary -I -c 1 url http://$host:$port/timestamp 357 | 358 | expect \ 359 | -glob {.timestamp: \d+ -> \d+} {} \ 360 | timeout { return {change timeout} } \ 361 | ; 362 | 363 | wait 364 | } -cleanup {} -match regexp -result {\d+ [^ ]+ 0 0} 365 | 366 | 367 | # Exit with a nonzero status if there are failed tests. 368 | set failed [expr {$tcltest::numTests(Failed) > 0}] 369 | 370 | tcltest::cleanupTests 371 | if {$failed} { 372 | exit 1 373 | } 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonwatch — like `watch -d`, but for JSON 2 | 3 | jsonwatch is a command-line utility that lets you track changes in JSON data delivered by a command or a web (HTTP/HTTPS) API. 4 | jsonwatch requests data from the source repeatedly at a set interval. 5 | It displays the differences when the data changes. 6 | It is similar but not identical to how [watch(1)](https://manpages.debian.org/stable/procps/watch.1.en.html) with the `-d` switch works for plain text. 7 | 8 | jsonwatch has been tested on Debian 12, Ubuntu 24.04, macOS 14, and Windows 10 and Server 2022. 9 | 10 | The two previous versions of jsonwatch are preserved in the branch 11 | [`python`](https://github.com/dbohdan/jsonwatch/tree/python) 12 | and 13 | [`haskell`](https://github.com/dbohdan/jsonwatch/tree/haskell). 14 | 15 | ## Installation 16 | 17 | Prebuilt binaries are available for Linux (x86_64) and Windows (x86). 18 | Binaries are attached to releases on the 19 | ["Releases"](https://github.com/dbohdan/jsonwatch/releases) 20 | page. 21 | 22 | ### Installing with Cargo 23 | 24 | ```shell 25 | cargo install jsonwatch 26 | ``` 27 | 28 | ### Building on Debian and Ubuntu 29 | 30 | Follow the instructions to build a static Linux binary of jsonwatch from the source code on recent Debian and Ubuntu. 31 | 32 | 1. Install [Rustup](https://rustup.rs/). 33 | Through Rustup, add the stable musl libc target for your CPU. 34 | 35 | ```shell 36 | rustup target add x86_64-unknown-linux-musl 37 | ``` 38 | 39 | 2. Install the build and test dependencies. 40 | Building requires [just](https://github.com/casey/just) 1.39 or later. 41 | 42 | ```shell 43 | sudo apt install build-essential expect musl-tools 44 | cargo install just 45 | ``` 46 | 47 | 3. Clone this repository. 48 | Build the binary. 49 | 50 | ```shell 51 | git clone https://github.com/dbohdan/jsonwatch 52 | cd jsonwatch 53 | just test 54 | just release-linux 55 | ``` 56 | 57 | ### Cross-compiling for Windows 58 | 59 | Follow the instructions to build a 32-bit Windows binary of jsonwatch on recent Debian and Ubuntu. 60 | 61 | 1. Install [Rustup](https://rustup.rs/). 62 | Through Rustup, add the i686 GNU ABI Windows target. 63 | 64 | ```shell 65 | rustup target add i686-pc-windows-gnu 66 | ``` 67 | 68 | 2. Install the build dependencies. 69 | Building requires [just](https://github.com/casey/just) 1.39 or later. 70 | 71 | ```shell 72 | sudo apt install build-essential mingw-w64 73 | cargo install just 74 | ``` 75 | 76 | 3. Configure Cargo for cross-compilation. 77 | Add the following to `~/.cargo/config`. 78 | 79 | ```toml 80 | [target.i686-pc-windows-gnu] 81 | linker = "/usr/bin/i686-w64-mingw32-gcc" 82 | ``` 83 | 84 | 4. Clone this repository. 85 | Build the binary. 86 | 87 | ```shell 88 | git clone https://github.com/dbohdan/jsonwatch 89 | cd jsonwatch 90 | just release-windows 91 | ``` 92 | 93 | ## Usage 94 | 95 | You must run jsonwatch with a subcommand. 96 | 97 | jsonwatch processes valid JSON. 98 | The following behavior applies: 99 | 100 | - If the data source (command or URL) produces an error (non-zero exit status or HTTP failure response), the stored data remains unchanged 101 | - Responses with invalid JSON, including empty responses, do not update the stored data 102 | 103 | Some security measures are in place: 104 | 105 | - Input data (command output or HTTP response body) printed with `-vv` has any control characters escaped to prevent display issues and security risks like ANSI escape sequence injection. 106 | Command output is also escaped because commands that access remote data sources are expected. 107 | - URL responses are limited to 128 MiB 108 | - Command output is not limited in size and is buffered in memory 109 | 110 | Control characters are escaped as follows (`\\` represents a literal backslash character): 111 | 112 | | Character | Escaped as | Example | 113 | |------------------------|----------------|-------------------------------| 114 | | Newline (`\n`) | Passed through | `A\nB` → `A\nB` | 115 | | Tab (`\t`) | Passed through | `A\tB` → `A\tB` | 116 | | Carriage return (`\r`) | `\\u{d}` | `A\rB` → `A\\u{d}B` | 117 | | Escape (`\x1b`) | `\\u{1b}` | `\x1b[32m` → `\\u{1b}[32m` | 118 | | Other control chars | `\\u{xx}` | ASCII BEL (`\x07`) → `\\u{7}` | 119 | 120 | Non-control characters are printed as is. 121 | 122 | ### Global options 123 | 124 | ```none 125 | Track changes in JSON data 126 | 127 | Usage: jsonwatch [OPTIONS] 128 | 129 | Commands: 130 | cmd Execute a command and track changes in the JSON output 131 | url Fetch a URL and track changes in the JSON data 132 | init Generate shell completions 133 | help Print this message or the help of the given subcommand(s) 134 | 135 | Options: 136 | -D, --no-date Don't print date and time for each diff 137 | -I, --no-initial-values Don't print initial JSON values 138 | -c, --changes Exit after a number of changes 139 | -n, --interval Polling interval in seconds [default: 2] 140 | -v, --verbose... Verbose mode ('-v' for errors, '-vv' for errors and 141 | input data) 142 | -h, --help Print help 143 | -V, --version Print version 144 | ``` 145 | 146 | ### `cmd` subcommand 147 | 148 | You can use `cmd` or `command` as the name of the subcommand. 149 | 150 | ```none 151 | Execute a command and track changes in the JSON output 152 | 153 | Usage: jsonwatch cmd [arg]... 154 | 155 | Arguments: 156 | Command to execute 157 | [arg]... Arguments to the command 158 | 159 | Options: 160 | -h, --help Print help 161 | ``` 162 | 163 | ### `url` subcommand 164 | 165 | ```none 166 | Fetch a URL and track changes in the JSON data 167 | 168 | Usage: jsonwatch url [OPTIONS] 169 | 170 | Arguments: 171 | URL to fetch 172 | 173 | Options: 174 | -A, --user-agent Custom user-agent string [default: curl/7.58.0] 175 | -H, --header
Custom headers in the format "X-Foo: bar" 176 | -h, --help Print help 177 | ``` 178 | 179 | ### `init` subcommand 180 | 181 | ```none 182 | Generate shell completions 183 | 184 | Usage: jsonwatch init 185 | 186 | Arguments: 187 | The shell to generate completions for [possible values: bash, 188 | elvish, fish, powershell, zsh] 189 | 190 | Options: 191 | -h, --help Print help 192 | ``` 193 | 194 | ## Use examples 195 | 196 | ### Command 197 | 198 | #### Testing jsonwatch 199 | 200 | This example uses the POSIX shell to generate random JSON test data. 201 | 202 | ```none 203 | $ jsonwatch -n 1 cmd sh -c "echo '{ \"filename\": \"'\$(mktemp -u)'\"}'" 204 | 205 | { 206 | "filename": "/tmp/tmp.dh3Y7LJTaK" 207 | } 208 | 2020-01-19T18:52:19+0000 .filename: "/tmp/tmp.dh3Y7LJTaK" -> "/tmp/tmp.i4s56VENEJ" 209 | 2020-01-19T18:52:20+0000 .filename: "/tmp/tmp.i4s56VENEJ" -> "/tmp/tmp.zzMUSn45Fc" 210 | 2020-01-19T18:52:21+0000 .filename: "/tmp/tmp.zzMUSn45Fc" -> "/tmp/tmp.Jj1cKt6VLr" 211 | 2020-01-19T18:52:22+0000 .filename: "/tmp/tmp.Jj1cKt6VLr" -> "/tmp/tmp.1LGk4ok8O2" 212 | 2020-01-19T18:52:23+0000 .filename: "/tmp/tmp.1LGk4ok8O2" -> "/tmp/tmp.wWulyho8Qj" 213 | ``` 214 | 215 | #### Docker 216 | 217 | The command in this example tracks Docker process information when you have a single running container. 218 | 219 | ```none 220 | $ jsonwatch -n 1 command docker ps -a "--format={{json .}}" 221 | 222 | 2020-01-19T18:57:20+0000 223 | + .Command: "\"bash\"" 224 | + .CreatedAt: "2020-01-19 18:57:20 +0000 UTC" 225 | + .ID: "dce7fb2194ed" 226 | + .Image: "i386/ubuntu:latest" 227 | + .Labels: "" 228 | + .LocalVolumes: "0" 229 | + .Mounts: "" 230 | + .Names: "dreamy_edison" 231 | + .Networks: "bridge" 232 | + .Ports: "" 233 | + .RunningFor: "Less than a second ago" 234 | + .Size: "0B" 235 | + .Status: "Created" 236 | 2020-01-19T18:57:21+0000 .RunningFor: "Less than a second ago" -> "1 second ago" 237 | 2020-01-19T18:57:23+0000 238 | .RunningFor: "1 second ago" -> "3 seconds ago" 239 | .Status: "Created" -> "Up 1 second" 240 | 2020-01-19T18:57:24+0000 241 | .RunningFor: "3 seconds ago" -> "4 seconds ago" 242 | .Status: "Up 1 second" -> "Up 2 seconds" 243 | 2020-01-19T18:57:25+0000 244 | .RunningFor: "4 seconds ago" -> "5 seconds ago" 245 | .Status: "Up 2 seconds" -> "Up 3 seconds" 246 | ``` 247 | 248 | For multiple running containers, you will need a more complex jsonwatch command. 249 | The command needs to transform the [JSON Lines](https://jsonlines.org/) output into a single JSON document. 250 | For example, it can be the following command with the POSIX shell and [jq](https://en.wikipedia.org/wiki/Jq_(programming_language)): 251 | 252 | ```shell 253 | jsonwatch -I cmd sh -c 'docker ps -a "--format={{json .}}" | jq -s .' 254 | ``` 255 | 256 | #### `cmd.exe` on Windows 257 | 258 | This example is a simple test on Windows. 259 | We start watching the output of a `cmd.exe` command, then manually edit the file the command prints and are shown the changes. 260 | 261 | ```none 262 | > jsonwatch command cmd.exe /c "type tests\weather1.json" 263 | 264 | { 265 | "clouds": { 266 | "all": 92 267 | }, 268 | "name": "Kiev", 269 | "coord": { 270 | "lat": 50.43, 271 | "lon": 30.52 272 | }, 273 | "sys": { 274 | "country": "UA", 275 | "message": 0.0051, 276 | "sunset": 1394985874, 277 | "sunrise": 1394942901 278 | }, 279 | "weather": [ 280 | { 281 | "main": "Snow", 282 | "id": 612, 283 | "icon": "13d", 284 | "description": "light shower sleet" 285 | }, 286 | { 287 | "main": "Rain", 288 | "id": 520, 289 | "icon": "09d", 290 | "description": "light intensity shower rain" 291 | } 292 | ], 293 | "rain": { 294 | "3h": 2 295 | }, 296 | "base": "cmc stations", 297 | "dt": 1394979003, 298 | "main": { 299 | "pressure": 974.8229, 300 | "humidity": 91, 301 | "temp_max": 277.45, 302 | "temp": 276.45, 303 | "temp_min": 276.15 304 | }, 305 | "id": 703448, 306 | "wind": { 307 | "speed": 10.27, 308 | "deg": 245.507 309 | }, 310 | "cod": 200 311 | } 312 | 2020-01-19T18:51:04+0000 + .test: true 313 | 2020-01-19T18:51:10+0000 .test: true -> false 314 | 2020-01-19T18:51:23+0000 - .test: false 315 | ``` 316 | 317 | ### URL 318 | 319 | #### Weather tracking 320 | 321 | The API in this example no longer works without a key. 322 | 323 | ```none 324 | $ jsonwatch --no-initial-values -n 300 url 'http://api.openweathermap.org/data/2.5/weather?q=Kiev,ua' 325 | 326 | 2014-03-17T23:06:19+0200 327 | + .rain.1h: 0.76 328 | - .rain.3h: 0.5 329 | .dt: 1395086402 -> 1395089402 330 | .main.temp: 279.07 -> 278.66 331 | .main.temp_max: 279.82 -> 280.15 332 | .main.temp_min: 277.95 -> 276.05 333 | .sys.message: 0.0353 -> 0.0083 334 | ``` 335 | 336 | #### Geolocation 337 | 338 | Try this on a mobile device. 339 | 340 | ```none 341 | $ jsonwatch -I -n 300 url https://ipinfo.io/ 342 | ``` 343 | 344 | ## License 345 | 346 | jsonwatch is distributed under the MIT license. 347 | See the file [`LICENSE`](LICENSE) for details. 348 | [Wapp](tests/vendor/wapp/wapp.tcl) is copyright (c) 2017 D. Richard Hipp and is distributed under the Simplified BSD License. 349 | -------------------------------------------------------------------------------- /src/diff/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::lcs; 2 | use serde_json::{map::Map, Value}; 3 | 4 | use std::{ 5 | cmp::{max, min}, 6 | collections::HashSet, 7 | fmt, 8 | ops::{Deref, DerefMut}, 9 | }; 10 | 11 | #[derive(Debug, PartialEq)] 12 | pub enum Op { 13 | Added(JsonPath, Value), 14 | Changed(JsonPath, Value, Value), 15 | Removed(JsonPath, Value), 16 | } 17 | 18 | pub type JsonPath = String; 19 | pub type JsonDiffContents = Vec; 20 | 21 | #[derive(Debug)] 22 | pub struct JsonDiff(JsonDiffContents); 23 | 24 | impl fmt::Display for JsonDiff { 25 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 26 | for op in self.iter() { 27 | match op { 28 | Op::Added(path, value) => { 29 | writeln!(f, "+ {}: {}", path, value).unwrap(); 30 | } 31 | Op::Changed(path, from_value, to_value) => { 32 | writeln!(f, "{}: {} -> {}", path, from_value, to_value) 33 | .unwrap(); 34 | } 35 | Op::Removed(path, value) => { 36 | writeln!(f, "- {}: {}", path, value).unwrap(); 37 | } 38 | } 39 | } 40 | 41 | fmt::Result::Ok(()) 42 | } 43 | } 44 | 45 | impl Deref for JsonDiff { 46 | type Target = JsonDiffContents; 47 | 48 | fn deref(&self) -> &Self::Target { 49 | &self.0 50 | } 51 | } 52 | 53 | impl DerefMut for JsonDiff { 54 | fn deref_mut(&mut self) -> &mut Self::Target { 55 | &mut self.0 56 | } 57 | } 58 | 59 | pub fn diff(prev: &Option, current: &Option) -> JsonDiff { 60 | let mut diff = JsonDiff(Vec::new()); 61 | let prefix: JsonPath = "".to_string(); 62 | 63 | match (prev, current) { 64 | (None, None) => {} 65 | (Some(v), None) => { 66 | diff_helper(&mut diff, &prefix, &v, &Value::Null); 67 | // Remove the placeholder Null. 68 | match &diff[..] { 69 | [Op::Changed(p, v, Value::Null)] => { 70 | diff[0] = Op::Removed(p.to_string(), v.clone()); 71 | } 72 | _ => { 73 | diff.pop(); 74 | } 75 | } 76 | } 77 | (None, Some(v)) => { 78 | diff_helper(&mut diff, &prefix, &Value::Null, &v); 79 | match &diff[..] { 80 | [Op::Changed(p, Value::Null, v)] => { 81 | diff[0] = Op::Added(p.to_string(), v.clone()); 82 | } 83 | _ => { 84 | diff.remove(0); 85 | } 86 | } 87 | } 88 | (Some(v1), Some(v2)) => { 89 | diff_helper(&mut diff, &prefix, &v1, &v2); 90 | } 91 | } 92 | 93 | diff 94 | } 95 | 96 | fn diff_helper( 97 | acc: &mut JsonDiff, 98 | prefix: &JsonPath, 99 | prev: &Value, 100 | current: &Value, 101 | ) { 102 | match (prev, current) { 103 | (Value::Array(a), Value::Array(b)) => { 104 | diff_array(acc, prefix, a, b); 105 | } 106 | (Value::Object(a), Value::Object(b)) => { 107 | diff_obj(acc, prefix, a, b); 108 | } 109 | (Value::Array(a), b) => { 110 | diff_array(acc, prefix, a, &Vec::new()); 111 | acc.push(Op::Added(prefix.clone(), b.clone())); 112 | } 113 | (Value::Object(a), b) => { 114 | diff_obj(acc, prefix, a, &Map::new()); 115 | acc.push(Op::Added(prefix.clone(), b.clone())); 116 | } 117 | (a, Value::Array(b)) => { 118 | acc.push(Op::Removed(prefix.clone(), a.clone())); 119 | diff_array(acc, prefix, &Vec::new(), b); 120 | } 121 | (a, Value::Object(b)) => { 122 | acc.push(Op::Removed(prefix.clone(), a.clone())); 123 | diff_obj(acc, prefix, &Map::new(), b); 124 | } 125 | (a, b) => { 126 | if a != b { 127 | acc.push(Op::Changed(prefix.to_string(), a.clone(), b.clone())); 128 | } 129 | } 130 | } 131 | } 132 | 133 | fn diff_array( 134 | acc: &mut JsonDiff, 135 | prefix: &JsonPath, 136 | a: &Vec, 137 | b: &Vec, 138 | ) { 139 | let lcs = lcs::lcs(a, b); 140 | 141 | let mut a_prev: usize = 0; 142 | let mut b_prev: usize = 0; 143 | 144 | for (a_i, b_i) in &lcs { 145 | let changed_range = max(a_prev, b_prev)..min(*a_i, *b_i); 146 | for i in changed_range.clone() { 147 | acc.push(Op::Changed( 148 | format!("{}.{}", prefix, i), 149 | a[i].clone(), 150 | b[i].clone(), 151 | )); 152 | } 153 | for i in a_prev..*a_i { 154 | if changed_range.contains(&i) { 155 | continue; 156 | }; 157 | acc.push(Op::Removed(format!("{}.{}", prefix, i), a[i].clone())); 158 | } 159 | for i in b_prev..*b_i { 160 | if changed_range.contains(&i) { 161 | continue; 162 | }; 163 | acc.push(Op::Added(format!("{}.{}", prefix, i), b[i].clone())); 164 | } 165 | 166 | a_prev = a_i + 1; 167 | b_prev = b_i + 1; 168 | } 169 | 170 | let changed_range = max(a_prev, b_prev)..min(a.len(), b.len()); 171 | for i in changed_range.clone() { 172 | acc.push(Op::Changed( 173 | format!("{}.{}", prefix, i), 174 | a[i].clone(), 175 | b[i].clone(), 176 | )); 177 | } 178 | for i in a_prev..a.len() { 179 | if changed_range.contains(&i) { 180 | continue; 181 | }; 182 | acc.push(Op::Removed(format!("{}.{}", prefix, i), a[i].clone())); 183 | } 184 | for i in b_prev..b.len() { 185 | if changed_range.contains(&i) { 186 | continue; 187 | }; 188 | acc.push(Op::Added(format!("{}.{}", prefix, i), b[i].clone())); 189 | } 190 | } 191 | 192 | fn diff_obj( 193 | acc: &mut JsonDiff, 194 | prefix: &JsonPath, 195 | a: &Map, 196 | b: &Map, 197 | ) { 198 | let mut a_keys: HashSet = HashSet::new(); 199 | 200 | for (k, a_v) in a { 201 | a_keys.insert(k.clone()); 202 | 203 | let new_prefix = format!("{}.{}", prefix, k); 204 | match b.get(k) { 205 | Some(b_v) => { 206 | diff_helper(acc, &new_prefix, a_v, b_v); 207 | } 208 | None => { 209 | acc.push(Op::Removed(new_prefix, a_v.clone())); 210 | } 211 | } 212 | } 213 | 214 | for (k, b_v) in b { 215 | let new_prefix = format!("{}.{}", prefix, k); 216 | if !a_keys.contains(k) { 217 | acc.push(Op::Added(new_prefix, b_v.clone())); 218 | } 219 | } 220 | } 221 | 222 | #[cfg(test)] 223 | mod tests { 224 | use super::*; 225 | 226 | fn vec_value_string(strs: &[&str]) -> Vec { 227 | let mut v: Vec = Vec::new(); 228 | for s in strs { 229 | v.push(Value::String(s.to_string())); 230 | } 231 | 232 | v 233 | } 234 | 235 | #[test] 236 | fn test_diff_array_1() { 237 | let a = vec_value_string(&["foo", "bar"]); 238 | let b = vec_value_string(&["foo", "bar", "baz"]); 239 | 240 | let mut acc = JsonDiff(Vec::new()); 241 | diff_array(&mut acc, &"".to_string(), &a, &b); 242 | 243 | assert_eq!(*acc, vec![Op::Added(".2".to_string(), Value::from("baz"))]); 244 | } 245 | 246 | #[test] 247 | fn test_diff_array_2() { 248 | let a = vec_value_string(&["foo", "bar", "baz"]); 249 | let b = vec_value_string(&["foo", "bar"]); 250 | 251 | let mut acc = JsonDiff(Vec::new()); 252 | diff_array(&mut acc, &"".to_string(), &a, &b); 253 | 254 | assert_eq!( 255 | *acc, 256 | vec![Op::Removed(".2".to_string(), Value::from("baz"))] 257 | ); 258 | } 259 | 260 | #[test] 261 | fn test_diff_array_3() { 262 | let a = vec_value_string(&["foo", "baz"]); 263 | let b = vec_value_string(&["foo", "bar", "baz"]); 264 | 265 | let mut acc = JsonDiff(Vec::new()); 266 | diff_array(&mut acc, &"".to_string(), &a, &b); 267 | 268 | assert_eq!( 269 | *acc, 270 | vec![Op::Added(".1".to_string(), Value::from("bar")),] 271 | ); 272 | } 273 | 274 | #[test] 275 | fn test_diff_array_4() { 276 | let a = vec_value_string(&["foo", "bar", "baz"]); 277 | let b = vec_value_string(&["foo", "baz"]); 278 | 279 | let mut acc = JsonDiff(Vec::new()); 280 | diff_array(&mut acc, &"".to_string(), &a, &b); 281 | 282 | assert_eq!( 283 | *acc, 284 | vec![Op::Removed(".1".to_string(), Value::from("bar")),] 285 | ); 286 | } 287 | 288 | #[test] 289 | fn test_diff_array_5() { 290 | let a = vec_value_string(&["bar", "baz"]); 291 | let b = vec_value_string(&["foo", "bar", "baz"]); 292 | 293 | let mut acc = JsonDiff(Vec::new()); 294 | diff_array(&mut acc, &"".to_string(), &a, &b); 295 | 296 | assert_eq!( 297 | *acc, 298 | vec![Op::Added(".0".to_string(), Value::from("foo")),] 299 | ); 300 | } 301 | 302 | #[test] 303 | fn test_diff_array_6() { 304 | let a = vec_value_string(&["foo", "bar", "baz"]); 305 | let b = vec_value_string(&["bar", "baz"]); 306 | 307 | let mut acc = JsonDiff(Vec::new()); 308 | diff_array(&mut acc, &"".to_string(), &a, &b); 309 | 310 | assert_eq!( 311 | *acc, 312 | vec![Op::Removed(".0".to_string(), Value::from("foo")),] 313 | ); 314 | } 315 | 316 | #[test] 317 | fn test_diff_array_7() { 318 | let a = vec![ 319 | Value::from("foo"), 320 | Value::from("bar"), 321 | Value::from(1), 322 | Value::from("a"), 323 | Value::from("b"), 324 | ]; 325 | let b = vec![ 326 | Value::from("baz"), 327 | Value::from("qux"), 328 | Value::from(1), 329 | Value::from("c"), 330 | Value::from("d"), 331 | ]; 332 | 333 | let mut acc = JsonDiff(Vec::new()); 334 | diff_array(&mut acc, &"".to_string(), &a, &b); 335 | 336 | assert_eq!( 337 | *acc, 338 | vec![ 339 | Op::Changed( 340 | ".0".to_string(), 341 | Value::from("foo"), 342 | Value::from("baz") 343 | ), 344 | Op::Changed( 345 | ".1".to_string(), 346 | Value::from("bar"), 347 | Value::from("qux") 348 | ), 349 | Op::Changed( 350 | ".3".to_string(), 351 | Value::from("a"), 352 | Value::from("c") 353 | ), 354 | Op::Changed( 355 | ".4".to_string(), 356 | Value::from("b"), 357 | Value::from("d") 358 | ), 359 | ] 360 | ); 361 | } 362 | 363 | #[test] 364 | fn test_diff_array_8() { 365 | let a = vec_value_string(&["foo", "baz"]); 366 | let b = vec_value_string(&["bar"]); 367 | 368 | let mut acc = JsonDiff(Vec::new()); 369 | diff_array(&mut acc, &"".to_string(), &a, &b); 370 | 371 | assert_eq!( 372 | *acc, 373 | vec![ 374 | Op::Changed( 375 | ".0".to_string(), 376 | Value::from("foo"), 377 | Value::from("bar") 378 | ), 379 | Op::Removed(".1".to_string(), Value::from("baz")), 380 | ] 381 | ); 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "adler2" 7 | version = "2.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 | 11 | [[package]] 12 | name = "android_system_properties" 13 | version = "0.1.5" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 16 | dependencies = [ 17 | "libc", 18 | ] 19 | 20 | [[package]] 21 | name = "anstream" 22 | version = "0.6.20" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" 25 | dependencies = [ 26 | "anstyle", 27 | "anstyle-parse", 28 | "anstyle-query", 29 | "anstyle-wincon", 30 | "colorchoice", 31 | "is_terminal_polyfill", 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle" 37 | version = "1.0.11" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 40 | 41 | [[package]] 42 | name = "anstyle-parse" 43 | version = "0.2.7" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 46 | dependencies = [ 47 | "utf8parse", 48 | ] 49 | 50 | [[package]] 51 | name = "anstyle-query" 52 | version = "1.1.4" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 55 | dependencies = [ 56 | "windows-sys 0.60.2", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-wincon" 61 | version = "3.0.10" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 64 | dependencies = [ 65 | "anstyle", 66 | "once_cell_polyfill", 67 | "windows-sys 0.60.2", 68 | ] 69 | 70 | [[package]] 71 | name = "autocfg" 72 | version = "1.5.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 75 | 76 | [[package]] 77 | name = "base64" 78 | version = "0.22.1" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 81 | 82 | [[package]] 83 | name = "bumpalo" 84 | version = "3.19.0" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 87 | 88 | [[package]] 89 | name = "bytes" 90 | version = "1.10.1" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 93 | 94 | [[package]] 95 | name = "cc" 96 | version = "1.2.37" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" 99 | dependencies = [ 100 | "find-msvc-tools", 101 | "shlex", 102 | ] 103 | 104 | [[package]] 105 | name = "cfg-if" 106 | version = "1.0.3" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 109 | 110 | [[package]] 111 | name = "chrono" 112 | version = "0.4.42" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 115 | dependencies = [ 116 | "iana-time-zone", 117 | "js-sys", 118 | "num-traits", 119 | "wasm-bindgen", 120 | "windows-link 0.2.0", 121 | ] 122 | 123 | [[package]] 124 | name = "clap" 125 | version = "4.5.47" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" 128 | dependencies = [ 129 | "clap_builder", 130 | "clap_derive", 131 | ] 132 | 133 | [[package]] 134 | name = "clap_builder" 135 | version = "4.5.47" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" 138 | dependencies = [ 139 | "anstream", 140 | "anstyle", 141 | "clap_lex", 142 | "strsim", 143 | ] 144 | 145 | [[package]] 146 | name = "clap_complete" 147 | version = "4.5.58" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "75bf0b32ad2e152de789bb635ea4d3078f6b838ad7974143e99b99f45a04af4a" 150 | dependencies = [ 151 | "clap", 152 | ] 153 | 154 | [[package]] 155 | name = "clap_derive" 156 | version = "4.5.47" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" 159 | dependencies = [ 160 | "heck", 161 | "proc-macro2", 162 | "quote", 163 | "syn", 164 | ] 165 | 166 | [[package]] 167 | name = "clap_lex" 168 | version = "0.7.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 171 | 172 | [[package]] 173 | name = "colorchoice" 174 | version = "1.0.4" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 177 | 178 | [[package]] 179 | name = "core-foundation-sys" 180 | version = "0.8.7" 181 | source = "registry+https://github.com/rust-lang/crates.io-index" 182 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 183 | 184 | [[package]] 185 | name = "crc32fast" 186 | version = "1.5.0" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 189 | dependencies = [ 190 | "cfg-if", 191 | ] 192 | 193 | [[package]] 194 | name = "equivalent" 195 | version = "1.0.2" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 198 | 199 | [[package]] 200 | name = "find-msvc-tools" 201 | version = "0.1.1" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" 204 | 205 | [[package]] 206 | name = "flate2" 207 | version = "1.1.2" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" 210 | dependencies = [ 211 | "crc32fast", 212 | "miniz_oxide", 213 | ] 214 | 215 | [[package]] 216 | name = "fnv" 217 | version = "1.0.7" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 220 | 221 | [[package]] 222 | name = "getrandom" 223 | version = "0.2.16" 224 | source = "registry+https://github.com/rust-lang/crates.io-index" 225 | checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 226 | dependencies = [ 227 | "cfg-if", 228 | "libc", 229 | "wasi", 230 | ] 231 | 232 | [[package]] 233 | name = "hashbrown" 234 | version = "0.15.5" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 237 | 238 | [[package]] 239 | name = "heck" 240 | version = "0.5.0" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 243 | 244 | [[package]] 245 | name = "http" 246 | version = "1.3.1" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 249 | dependencies = [ 250 | "bytes", 251 | "fnv", 252 | "itoa", 253 | ] 254 | 255 | [[package]] 256 | name = "httparse" 257 | version = "1.10.1" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 260 | 261 | [[package]] 262 | name = "iana-time-zone" 263 | version = "0.1.64" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 266 | dependencies = [ 267 | "android_system_properties", 268 | "core-foundation-sys", 269 | "iana-time-zone-haiku", 270 | "js-sys", 271 | "log", 272 | "wasm-bindgen", 273 | "windows-core", 274 | ] 275 | 276 | [[package]] 277 | name = "iana-time-zone-haiku" 278 | version = "0.1.2" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 281 | dependencies = [ 282 | "cc", 283 | ] 284 | 285 | [[package]] 286 | name = "indexmap" 287 | version = "2.11.3" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" 290 | dependencies = [ 291 | "equivalent", 292 | "hashbrown", 293 | ] 294 | 295 | [[package]] 296 | name = "is_terminal_polyfill" 297 | version = "1.70.1" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 300 | 301 | [[package]] 302 | name = "itoa" 303 | version = "1.0.15" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 306 | 307 | [[package]] 308 | name = "js-sys" 309 | version = "0.3.78" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" 312 | dependencies = [ 313 | "once_cell", 314 | "wasm-bindgen", 315 | ] 316 | 317 | [[package]] 318 | name = "jsonwatch" 319 | version = "0.11.0" 320 | dependencies = [ 321 | "chrono", 322 | "clap", 323 | "clap_complete", 324 | "serde_json", 325 | "ureq", 326 | ] 327 | 328 | [[package]] 329 | name = "libc" 330 | version = "0.2.175" 331 | source = "registry+https://github.com/rust-lang/crates.io-index" 332 | checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" 333 | 334 | [[package]] 335 | name = "log" 336 | version = "0.4.28" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 339 | 340 | [[package]] 341 | name = "memchr" 342 | version = "2.7.5" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" 345 | 346 | [[package]] 347 | name = "miniz_oxide" 348 | version = "0.8.9" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 351 | dependencies = [ 352 | "adler2", 353 | ] 354 | 355 | [[package]] 356 | name = "num-traits" 357 | version = "0.2.19" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 360 | dependencies = [ 361 | "autocfg", 362 | ] 363 | 364 | [[package]] 365 | name = "once_cell" 366 | version = "1.21.3" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 369 | 370 | [[package]] 371 | name = "once_cell_polyfill" 372 | version = "1.70.1" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 375 | 376 | [[package]] 377 | name = "percent-encoding" 378 | version = "2.3.2" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 381 | 382 | [[package]] 383 | name = "proc-macro2" 384 | version = "1.0.101" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 387 | dependencies = [ 388 | "unicode-ident", 389 | ] 390 | 391 | [[package]] 392 | name = "quote" 393 | version = "1.0.40" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 396 | dependencies = [ 397 | "proc-macro2", 398 | ] 399 | 400 | [[package]] 401 | name = "ring" 402 | version = "0.17.14" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 405 | dependencies = [ 406 | "cc", 407 | "cfg-if", 408 | "getrandom", 409 | "libc", 410 | "untrusted", 411 | "windows-sys 0.52.0", 412 | ] 413 | 414 | [[package]] 415 | name = "rustls" 416 | version = "0.23.31" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" 419 | dependencies = [ 420 | "log", 421 | "once_cell", 422 | "ring", 423 | "rustls-pki-types", 424 | "rustls-webpki", 425 | "subtle", 426 | "zeroize", 427 | ] 428 | 429 | [[package]] 430 | name = "rustls-pemfile" 431 | version = "2.2.0" 432 | source = "registry+https://github.com/rust-lang/crates.io-index" 433 | checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 434 | dependencies = [ 435 | "rustls-pki-types", 436 | ] 437 | 438 | [[package]] 439 | name = "rustls-pki-types" 440 | version = "1.12.0" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 443 | dependencies = [ 444 | "zeroize", 445 | ] 446 | 447 | [[package]] 448 | name = "rustls-webpki" 449 | version = "0.103.6" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" 452 | dependencies = [ 453 | "ring", 454 | "rustls-pki-types", 455 | "untrusted", 456 | ] 457 | 458 | [[package]] 459 | name = "rustversion" 460 | version = "1.0.22" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 463 | 464 | [[package]] 465 | name = "ryu" 466 | version = "1.0.20" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 469 | 470 | [[package]] 471 | name = "serde" 472 | version = "1.0.225" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" 475 | dependencies = [ 476 | "serde_core", 477 | ] 478 | 479 | [[package]] 480 | name = "serde_core" 481 | version = "1.0.225" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" 484 | dependencies = [ 485 | "serde_derive", 486 | ] 487 | 488 | [[package]] 489 | name = "serde_derive" 490 | version = "1.0.225" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" 493 | dependencies = [ 494 | "proc-macro2", 495 | "quote", 496 | "syn", 497 | ] 498 | 499 | [[package]] 500 | name = "serde_json" 501 | version = "1.0.145" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 504 | dependencies = [ 505 | "indexmap", 506 | "itoa", 507 | "memchr", 508 | "ryu", 509 | "serde", 510 | "serde_core", 511 | ] 512 | 513 | [[package]] 514 | name = "shlex" 515 | version = "1.3.0" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 518 | 519 | [[package]] 520 | name = "strsim" 521 | version = "0.11.1" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 524 | 525 | [[package]] 526 | name = "subtle" 527 | version = "2.6.1" 528 | source = "registry+https://github.com/rust-lang/crates.io-index" 529 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 530 | 531 | [[package]] 532 | name = "syn" 533 | version = "2.0.106" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 536 | dependencies = [ 537 | "proc-macro2", 538 | "quote", 539 | "unicode-ident", 540 | ] 541 | 542 | [[package]] 543 | name = "unicode-ident" 544 | version = "1.0.19" 545 | source = "registry+https://github.com/rust-lang/crates.io-index" 546 | checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 547 | 548 | [[package]] 549 | name = "untrusted" 550 | version = "0.9.0" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 553 | 554 | [[package]] 555 | name = "ureq" 556 | version = "3.1.2" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" 559 | dependencies = [ 560 | "base64", 561 | "flate2", 562 | "log", 563 | "percent-encoding", 564 | "rustls", 565 | "rustls-pemfile", 566 | "rustls-pki-types", 567 | "ureq-proto", 568 | "utf-8", 569 | "webpki-roots", 570 | ] 571 | 572 | [[package]] 573 | name = "ureq-proto" 574 | version = "0.5.2" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" 577 | dependencies = [ 578 | "base64", 579 | "http", 580 | "httparse", 581 | "log", 582 | ] 583 | 584 | [[package]] 585 | name = "utf-8" 586 | version = "0.7.6" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 589 | 590 | [[package]] 591 | name = "utf8parse" 592 | version = "0.2.2" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 595 | 596 | [[package]] 597 | name = "wasi" 598 | version = "0.11.1+wasi-snapshot-preview1" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 601 | 602 | [[package]] 603 | name = "wasm-bindgen" 604 | version = "0.2.101" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" 607 | dependencies = [ 608 | "cfg-if", 609 | "once_cell", 610 | "rustversion", 611 | "wasm-bindgen-macro", 612 | "wasm-bindgen-shared", 613 | ] 614 | 615 | [[package]] 616 | name = "wasm-bindgen-backend" 617 | version = "0.2.101" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" 620 | dependencies = [ 621 | "bumpalo", 622 | "log", 623 | "proc-macro2", 624 | "quote", 625 | "syn", 626 | "wasm-bindgen-shared", 627 | ] 628 | 629 | [[package]] 630 | name = "wasm-bindgen-macro" 631 | version = "0.2.101" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" 634 | dependencies = [ 635 | "quote", 636 | "wasm-bindgen-macro-support", 637 | ] 638 | 639 | [[package]] 640 | name = "wasm-bindgen-macro-support" 641 | version = "0.2.101" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" 644 | dependencies = [ 645 | "proc-macro2", 646 | "quote", 647 | "syn", 648 | "wasm-bindgen-backend", 649 | "wasm-bindgen-shared", 650 | ] 651 | 652 | [[package]] 653 | name = "wasm-bindgen-shared" 654 | version = "0.2.101" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" 657 | dependencies = [ 658 | "unicode-ident", 659 | ] 660 | 661 | [[package]] 662 | name = "webpki-roots" 663 | version = "1.0.2" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" 666 | dependencies = [ 667 | "rustls-pki-types", 668 | ] 669 | 670 | [[package]] 671 | name = "windows-core" 672 | version = "0.62.0" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" 675 | dependencies = [ 676 | "windows-implement", 677 | "windows-interface", 678 | "windows-link 0.2.0", 679 | "windows-result", 680 | "windows-strings", 681 | ] 682 | 683 | [[package]] 684 | name = "windows-implement" 685 | version = "0.60.0" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 688 | dependencies = [ 689 | "proc-macro2", 690 | "quote", 691 | "syn", 692 | ] 693 | 694 | [[package]] 695 | name = "windows-interface" 696 | version = "0.59.1" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 699 | dependencies = [ 700 | "proc-macro2", 701 | "quote", 702 | "syn", 703 | ] 704 | 705 | [[package]] 706 | name = "windows-link" 707 | version = "0.1.3" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 710 | 711 | [[package]] 712 | name = "windows-link" 713 | version = "0.2.0" 714 | source = "registry+https://github.com/rust-lang/crates.io-index" 715 | checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 716 | 717 | [[package]] 718 | name = "windows-result" 719 | version = "0.4.0" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 722 | dependencies = [ 723 | "windows-link 0.2.0", 724 | ] 725 | 726 | [[package]] 727 | name = "windows-strings" 728 | version = "0.5.0" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 731 | dependencies = [ 732 | "windows-link 0.2.0", 733 | ] 734 | 735 | [[package]] 736 | name = "windows-sys" 737 | version = "0.52.0" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 740 | dependencies = [ 741 | "windows-targets 0.52.6", 742 | ] 743 | 744 | [[package]] 745 | name = "windows-sys" 746 | version = "0.60.2" 747 | source = "registry+https://github.com/rust-lang/crates.io-index" 748 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 749 | dependencies = [ 750 | "windows-targets 0.53.3", 751 | ] 752 | 753 | [[package]] 754 | name = "windows-targets" 755 | version = "0.52.6" 756 | source = "registry+https://github.com/rust-lang/crates.io-index" 757 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 758 | dependencies = [ 759 | "windows_aarch64_gnullvm 0.52.6", 760 | "windows_aarch64_msvc 0.52.6", 761 | "windows_i686_gnu 0.52.6", 762 | "windows_i686_gnullvm 0.52.6", 763 | "windows_i686_msvc 0.52.6", 764 | "windows_x86_64_gnu 0.52.6", 765 | "windows_x86_64_gnullvm 0.52.6", 766 | "windows_x86_64_msvc 0.52.6", 767 | ] 768 | 769 | [[package]] 770 | name = "windows-targets" 771 | version = "0.53.3" 772 | source = "registry+https://github.com/rust-lang/crates.io-index" 773 | checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" 774 | dependencies = [ 775 | "windows-link 0.1.3", 776 | "windows_aarch64_gnullvm 0.53.0", 777 | "windows_aarch64_msvc 0.53.0", 778 | "windows_i686_gnu 0.53.0", 779 | "windows_i686_gnullvm 0.53.0", 780 | "windows_i686_msvc 0.53.0", 781 | "windows_x86_64_gnu 0.53.0", 782 | "windows_x86_64_gnullvm 0.53.0", 783 | "windows_x86_64_msvc 0.53.0", 784 | ] 785 | 786 | [[package]] 787 | name = "windows_aarch64_gnullvm" 788 | version = "0.52.6" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 791 | 792 | [[package]] 793 | name = "windows_aarch64_gnullvm" 794 | version = "0.53.0" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 797 | 798 | [[package]] 799 | name = "windows_aarch64_msvc" 800 | version = "0.52.6" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 803 | 804 | [[package]] 805 | name = "windows_aarch64_msvc" 806 | version = "0.53.0" 807 | source = "registry+https://github.com/rust-lang/crates.io-index" 808 | checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 809 | 810 | [[package]] 811 | name = "windows_i686_gnu" 812 | version = "0.52.6" 813 | source = "registry+https://github.com/rust-lang/crates.io-index" 814 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 815 | 816 | [[package]] 817 | name = "windows_i686_gnu" 818 | version = "0.53.0" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 821 | 822 | [[package]] 823 | name = "windows_i686_gnullvm" 824 | version = "0.52.6" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 827 | 828 | [[package]] 829 | name = "windows_i686_gnullvm" 830 | version = "0.53.0" 831 | source = "registry+https://github.com/rust-lang/crates.io-index" 832 | checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 833 | 834 | [[package]] 835 | name = "windows_i686_msvc" 836 | version = "0.52.6" 837 | source = "registry+https://github.com/rust-lang/crates.io-index" 838 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 839 | 840 | [[package]] 841 | name = "windows_i686_msvc" 842 | version = "0.53.0" 843 | source = "registry+https://github.com/rust-lang/crates.io-index" 844 | checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 845 | 846 | [[package]] 847 | name = "windows_x86_64_gnu" 848 | version = "0.52.6" 849 | source = "registry+https://github.com/rust-lang/crates.io-index" 850 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 851 | 852 | [[package]] 853 | name = "windows_x86_64_gnu" 854 | version = "0.53.0" 855 | source = "registry+https://github.com/rust-lang/crates.io-index" 856 | checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 857 | 858 | [[package]] 859 | name = "windows_x86_64_gnullvm" 860 | version = "0.52.6" 861 | source = "registry+https://github.com/rust-lang/crates.io-index" 862 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 863 | 864 | [[package]] 865 | name = "windows_x86_64_gnullvm" 866 | version = "0.53.0" 867 | source = "registry+https://github.com/rust-lang/crates.io-index" 868 | checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 869 | 870 | [[package]] 871 | name = "windows_x86_64_msvc" 872 | version = "0.52.6" 873 | source = "registry+https://github.com/rust-lang/crates.io-index" 874 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 875 | 876 | [[package]] 877 | name = "windows_x86_64_msvc" 878 | version = "0.53.0" 879 | source = "registry+https://github.com/rust-lang/crates.io-index" 880 | checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 881 | 882 | [[package]] 883 | name = "zeroize" 884 | version = "1.8.1" 885 | source = "registry+https://github.com/rust-lang/crates.io-index" 886 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 887 | -------------------------------------------------------------------------------- /tests/vendor/wapp/wapp.tcl: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 D. Richard Hipp 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the Simplified BSD License (also 5 | # known as the "2-Clause License" or "FreeBSD License".) 6 | # 7 | # This program is distributed in the hope that it will be useful, 8 | # but without any warranty; without even the implied warranty of 9 | # merchantability or fitness for a particular purpose. 10 | # 11 | #--------------------------------------------------------------------------- 12 | # 13 | # Design rules: 14 | # 15 | # (1) All identifiers in the global namespace begin with "wapp" 16 | # 17 | # (2) Indentifiers intended for internal use only begin with "wappInt" 18 | # 19 | if {$::tcl_version < 8.6} {package require Tcl 8.6} 20 | 21 | # Add text to the end of the HTTP reply. No interpretation or transformation 22 | # of the text is performs. The argument should be enclosed within {...} 23 | # 24 | proc wapp {txt} { 25 | global wapp 26 | dict append wapp .reply $txt 27 | } 28 | 29 | # Add text to the page under construction. Do no escaping on the text. 30 | # 31 | # Though "unsafe" in general, there are uses for this kind of thing. 32 | # For example, if you want to return the complete, unmodified content of 33 | # a file: 34 | # 35 | # set fd [open content.html rb] 36 | # wapp-unsafe [read $fd] 37 | # close $fd 38 | # 39 | # You could do the same thing using ordinary "wapp" instead of "wapp-unsafe". 40 | # The difference is that wapp-safety-check will complain about the misuse 41 | # of "wapp", but it assumes that the person who write "wapp-unsafe" understands 42 | # the risks. 43 | # 44 | # Though occasionally necessary, the use of this interface should be minimized. 45 | # 46 | proc wapp-unsafe {txt} { 47 | global wapp 48 | dict append wapp .reply $txt 49 | } 50 | 51 | # Add text to the end of the reply under construction. The following 52 | # substitutions are made: 53 | # 54 | # %html(...) Escape text for inclusion in HTML 55 | # %url(...) Escape text for use as a URL 56 | # %qp(...) Escape text for use as a URI query parameter 57 | # %string(...) Escape text for use within a JSON string 58 | # %unsafe(...) No transformations of the text 59 | # 60 | # The substitutions above terminate at the first ")" character. If the 61 | # text of the TCL string in ... contains ")" characters itself, use instead: 62 | # 63 | # %html%(...)% 64 | # %url%(...)% 65 | # %qp%(...)% 66 | # %string%(...)% 67 | # %unsafe%(...)% 68 | # 69 | # In other words, use "%(...)%" instead of "(...)" to include the TCL string 70 | # to substitute. 71 | # 72 | # The %unsafe substitution should be avoided whenever possible, obviously. 73 | # In addition to the substitutions above, the text also does backslash 74 | # escapes. 75 | # 76 | # The wapp-trim proc works the same as wapp-subst except that it also removes 77 | # whitespace from the left margin, so that the generated HTML/CSS/Javascript 78 | # does not appear to be indented when delivered to the client web browser. 79 | # 80 | if {$tcl_version>=8.7} { 81 | proc wapp-subst {txt} { 82 | global wapp 83 | regsub -all -command \ 84 | {%(html|url|qp|string|unsafe){1,1}?(|%)\((.+)\)\2} $txt wappInt-enc txt 85 | dict append wapp .reply [subst -novariables -nocommand $txt] 86 | } 87 | proc wapp-trim {txt} { 88 | global wapp 89 | regsub -all {\n\s+} [string trim $txt] \n txt 90 | regsub -all -command \ 91 | {%(html|url|qp|string|unsafe){1,1}?(|%)\((.+)\)\2} $txt wappInt-enc txt 92 | dict append wapp .reply [subst -novariables -nocommand $txt] 93 | } 94 | proc wappInt-enc {all mode nu1 txt} { 95 | return [uplevel 2 "wappInt-enc-$mode \"$txt\""] 96 | } 97 | } else { 98 | proc wapp-subst {txt} { 99 | global wapp 100 | regsub -all {%(html|url|qp|string|unsafe){1,1}?(|%)\((.+)\)\2} $txt \ 101 | {[wappInt-enc-\1 "\3"]} txt 102 | dict append wapp .reply [uplevel 1 [list subst -novariables $txt]] 103 | } 104 | proc wapp-trim {txt} { 105 | global wapp 106 | regsub -all {\n\s+} [string trim $txt] \n txt 107 | regsub -all {%(html|url|qp|string|unsafe){1,1}?(|%)\((.+)\)\2} $txt \ 108 | {[wappInt-enc-\1 "\3"]} txt 109 | dict append wapp .reply [uplevel 1 [list subst -novariables $txt]] 110 | } 111 | } 112 | 113 | # There must be a wappInt-enc-NAME routine for each possible substitution 114 | # in wapp-subst. Thus there are routines for "html", "url", "qp", and "unsafe". 115 | # 116 | # wappInt-enc-html Escape text so that it is safe to use in the 117 | # body of an HTML document. 118 | # 119 | # wappInt-enc-url Escape text so that it is safe to pass as an 120 | # argument to href= and src= attributes in HTML. 121 | # 122 | # wappInt-enc-qp Escape text so that it is safe to use as the 123 | # value of a query parameter in a URL or in 124 | # post data or in a cookie. 125 | # 126 | # wappInt-enc-string Escape ", ', \, and < for using inside of a 127 | # javascript string literal. The < character 128 | # is escaped to prevent "" from causing 129 | # problems in embedded javascript. 130 | # 131 | # wappInt-enc-unsafe Perform no encoding at all. Unsafe. 132 | # 133 | proc wappInt-enc-html {txt} { 134 | return [string map {& & < < > > \" " \\ \} $txt] 135 | } 136 | proc wappInt-enc-unsafe {txt} { 137 | return $txt 138 | } 139 | proc wappInt-enc-url {s} { 140 | if {[regsub -all {[^-{}\\@~?=#_.:/a-zA-Z0-9]} $s {[wappInt-%HHchar {&}]} s]} { 141 | set s [subst -novar -noback $s] 142 | } 143 | if {[regsub -all {[\\{}]} $s {[wappInt-%HHchar \\&]} s]} { 144 | set s [subst -novar -noback $s] 145 | } 146 | return $s 147 | } 148 | proc wappInt-enc-qp {s} { 149 | if {[regsub -all {[^-{}\\_.a-zA-Z0-9]} $s {[wappInt-%HHchar {&}]} s]} { 150 | set s [subst -novar -noback $s] 151 | } 152 | if {[regsub -all {[\\{}]} $s {[wappInt-%HHchar \\&]} s]} { 153 | set s [subst -novar -noback $s] 154 | } 155 | return $s 156 | } 157 | proc wappInt-enc-string {s} { 158 | return [string map {\\ \\\\ \" \\\" ' \\' < \\u003c \n \\n \r \\r 159 | \f \\f \t \\t \x01 \\u0001 \x02 \\u0002 \x03 \\u0003 160 | \x04 \\u0004 \x05 \\u0005 \x06 \\u0006 \x07 \\u0007 161 | \x0b \\u000b \x0e \\u000e \x0f \\u000f \x10 \\u0010 162 | \x11 \\u0011 \x12 \\u0012 \x13 \\u0013 \x14 \\u0014 163 | \x15 \\u0015 \x16 \\u0016 \x17 \\u0017 \x18 \\u0018 164 | \x19 \\u0019 \x1a \\u001a \x1b \\u001b \x1c \\u001c 165 | \x1d \\u001d \x1e \\u001e \x1f \\u001f} $s] 166 | } 167 | 168 | # This is a helper routine for wappInt-enc-url and wappInt-enc-qp. It returns 169 | # an appropriate %HH encoding for the single character c. If c is a unicode 170 | # character, then this routine might return multiple bytes: %HH%HH%HH 171 | # 172 | proc wappInt-%HHchar {c} { 173 | if {$c==" "} {return +} 174 | return [regsub -all .. [binary encode hex [encoding convertto utf-8 $c]] {%&}] 175 | } 176 | 177 | 178 | # Undo the www-url-encoded format. 179 | # 180 | # HT: This code stolen from ncgi.tcl 181 | # 182 | proc wappInt-decode-url {str} { 183 | set str [string map [list + { } "\\" "\\\\" \[ \\\[ \] \\\]] $str] 184 | regsub -all -- \ 185 | {%([Ee][A-Fa-f0-9])%([89ABab][A-Fa-f0-9])%([89ABab][A-Fa-f0-9])} \ 186 | $str {[encoding convertfrom utf-8 [binary decode hex \1\2\3]]} str 187 | regsub -all -- \ 188 | {%([CDcd][A-Fa-f0-9])%([89ABab][A-Fa-f0-9])} \ 189 | $str {[encoding convertfrom utf-8 [binary decode hex \1\2]]} str 190 | regsub -all -- {%([0-7][A-Fa-f0-9])} $str {\\u00\1} str 191 | return [subst -novar $str] 192 | } 193 | 194 | # Reset the document back to an empty string. 195 | # 196 | proc wapp-reset {} { 197 | global wapp 198 | dict set wapp .reply {} 199 | } 200 | 201 | # Change the mime-type of the result document. 202 | # 203 | proc wapp-mimetype {x} { 204 | global wapp 205 | dict set wapp .mimetype $x 206 | } 207 | 208 | # Change the reply code. 209 | # 210 | proc wapp-reply-code {x} { 211 | global wapp 212 | dict set wapp .reply-code $x 213 | } 214 | 215 | # Set a cookie 216 | # 217 | proc wapp-set-cookie {name value} { 218 | global wapp 219 | dict lappend wapp .new-cookies $name $value 220 | } 221 | 222 | # Unset a cookie 223 | # 224 | proc wapp-clear-cookie {name} { 225 | wapp-set-cookie $name {} 226 | } 227 | 228 | # Add extra entries to the reply header 229 | # 230 | proc wapp-reply-extra {name value} { 231 | global wapp 232 | dict lappend wapp .reply-extra $name $value 233 | } 234 | 235 | # Specifies how the web-page under construction should be cached. 236 | # The argument should be one of: 237 | # 238 | # no-cache 239 | # max-age=N (for some integer number of seconds, N) 240 | # private,max-age=N 241 | # 242 | proc wapp-cache-control {x} { 243 | wapp-reply-extra Cache-Control $x 244 | } 245 | 246 | # Redirect to a different web page 247 | # 248 | proc wapp-redirect {uri} { 249 | wapp-reset 250 | wapp-reply-code {303 Redirect} 251 | wapp-reply-extra Location $uri 252 | } 253 | 254 | # Return the value of a wapp parameter 255 | # 256 | proc wapp-param {name {dflt {}}} { 257 | global wapp 258 | if {![dict exists $wapp $name]} {return $dflt} 259 | return [dict get $wapp $name] 260 | } 261 | 262 | # Return true if a and only if the wapp parameter $name exists 263 | # 264 | proc wapp-param-exists {name} { 265 | global wapp 266 | return [dict exists $wapp $name] 267 | } 268 | 269 | # Set the value of a wapp parameter 270 | # 271 | proc wapp-set-param {name value} { 272 | global wapp 273 | dict set wapp $name $value 274 | } 275 | 276 | # Return all parameter names that match the GLOB pattern, or all 277 | # names if the GLOB pattern is omitted. 278 | # 279 | proc wapp-param-list {{glob {*}}} { 280 | global wapp 281 | return [dict keys $wapp $glob] 282 | } 283 | 284 | # By default, Wapp does not decode query parameters and POST parameters 285 | # for cross-origin requests. This is a security restriction, designed to 286 | # help prevent cross-site request forgery (CSRF) attacks. 287 | # 288 | # As a consequence of this restriction, URLs for sites generated by Wapp 289 | # that contain query parameters will not work as URLs found in other 290 | # websites. You cannot create a link from a second website into a Wapp 291 | # website if the link contains query planner, by default. 292 | # 293 | # Of course, it is sometimes desirable to allow query parameters on external 294 | # links. For URLs for which this is safe, the application should invoke 295 | # wapp-allow-xorigin-params. This procedure tells Wapp that it is safe to 296 | # go ahead and decode the query parameters even for cross-site requests. 297 | # 298 | # In other words, for Wapp security is the default setting. Individual pages 299 | # need to actively disable the cross-site request security if those pages 300 | # are safe for cross-site access. 301 | # 302 | proc wapp-allow-xorigin-params {} { 303 | global wapp 304 | if {![dict exists $wapp .qp] && ![dict get $wapp SAME_ORIGIN]} { 305 | wappInt-decode-query-params 306 | } 307 | } 308 | 309 | # Set the content-security-policy. 310 | # 311 | # The default content-security-policy is very strict: "default-src 'self'" 312 | # The default policy prohibits the use of in-line javascript or CSS. 313 | # 314 | # Provide an alternative CSP as the argument. Or use "off" to disable 315 | # the CSP completely. 316 | # 317 | proc wapp-content-security-policy {val} { 318 | global wapp 319 | if {$val=="off"} { 320 | dict unset wapp .csp 321 | } else { 322 | dict set wapp .csp $val 323 | } 324 | } 325 | 326 | # Examine the bodys of all procedures in this program looking for 327 | # unsafe calls to various Wapp interfaces. Return a text string 328 | # containing warnings. Return an empty string if all is ok. 329 | # 330 | # This routine is advisory only. It misses some constructs that are 331 | # dangerous and flags others that are safe. 332 | # 333 | proc wapp-safety-check {} { 334 | set res {} 335 | foreach p [info command] { 336 | set ln 0 337 | foreach x [split [info body $p] \n] { 338 | incr ln 339 | if {[regexp {^[ \t]*wapp[ \t]+([^\n]+)} $x all tail] 340 | && [string index $tail 0]!="\173" 341 | && [regexp {[[$]} $tail] 342 | } { 343 | append res "$p:$ln: unsafe \"wapp\" call: \"[string trim $x]\"\n" 344 | } 345 | if {[regexp {^[ \t]*wapp-(subst|trim)[ \t]+[^\173]} $x all cx]} { 346 | append res "$p:$ln: unsafe \"wapp-$cx\" call: \"[string trim $x]\"\n" 347 | } 348 | } 349 | } 350 | return $res 351 | } 352 | 353 | # Return a string that descripts the current environment. Applications 354 | # might find this useful for debugging. 355 | # 356 | proc wapp-debug-env {} { 357 | global wapp 358 | set out {} 359 | foreach var [lsort [dict keys $wapp]] { 360 | if {[string index $var 0]=="."} continue 361 | append out "$var = [list [dict get $wapp $var]]\n" 362 | } 363 | append out "\[pwd\] = [list [pwd]]\n" 364 | return $out 365 | } 366 | 367 | # Tracing function for each HTTP request. This is overridden by wapp-start 368 | # if tracing is enabled. 369 | # 370 | proc wappInt-trace {} {} 371 | 372 | # Start up a listening socket. Arrange to invoke wappInt-new-connection 373 | # for each inbound HTTP connection. 374 | # 375 | # port Listen on this TCP port. 0 means to select a port 376 | # that is not currently in use 377 | # 378 | # wappmode One of "scgi", "remote-scgi", "server", or "local". 379 | # 380 | # fromip If not {}, then reject all requests from IP addresses 381 | # other than $fromip 382 | # 383 | proc wappInt-start-listener {port wappmode fromip} { 384 | if {[string match *scgi $wappmode]} { 385 | set type SCGI 386 | set server [list wappInt-new-connection \ 387 | wappInt-scgi-readable $wappmode $fromip] 388 | } else { 389 | set type HTTP 390 | set server [list wappInt-new-connection \ 391 | wappInt-http-readable $wappmode $fromip] 392 | } 393 | if {$wappmode=="local" || $wappmode=="scgi"} { 394 | set x [socket -server $server -myaddr 127.0.0.1 $port] 395 | } else { 396 | set x [socket -server $server $port] 397 | } 398 | set coninfo [chan configure $x -sockname] 399 | set port [lindex $coninfo 2] 400 | if {$wappmode=="local"} { 401 | wappInt-start-browser http://127.0.0.1:$port/ 402 | } elseif {$fromip!=""} { 403 | puts "Listening for $type requests on TCP port $port from IP $fromip" 404 | } else { 405 | puts "Listening for $type requests on TCP port $port" 406 | } 407 | } 408 | 409 | # Start a web-browser and point it at $URL 410 | # 411 | proc wappInt-start-browser {url} { 412 | global tcl_platform 413 | if {$tcl_platform(platform)=="windows"} { 414 | exec cmd /c start $url & 415 | } elseif {$tcl_platform(os)=="Darwin"} { 416 | exec open $url & 417 | } elseif {[catch {exec -ignorestderr xdg-open $url}]} { 418 | exec firefox $url & 419 | } 420 | } 421 | 422 | # This routine is a "socket -server" callback. The $chan, $ip, and $port 423 | # arguments are added by the socket command. 424 | # 425 | # Arrange to invoke $callback when content is available on the new socket. 426 | # The $callback will process inbound HTTP or SCGI content. Reject the 427 | # request if $fromip is not an empty string and does not match $ip. 428 | # 429 | proc wappInt-new-connection {callback wappmode fromip chan ip port} { 430 | upvar #0 wappInt-$chan W 431 | if {$fromip!="" && ![string match $fromip $ip]} { 432 | close $chan 433 | return 434 | } 435 | set W [dict create REMOTE_ADDR $ip REMOTE_PORT $port WAPP_MODE $wappmode \ 436 | .header {}] 437 | fconfigure $chan -blocking 0 -translation binary 438 | fileevent $chan readable [list $callback $chan] 439 | } 440 | 441 | # Close an input channel 442 | # 443 | proc wappInt-close-channel {chan} { 444 | if {$chan=="stdout"} { 445 | # This happens after completing a CGI request 446 | exit 0 447 | } else { 448 | unset ::wappInt-$chan 449 | close $chan 450 | } 451 | } 452 | 453 | # Process new text received on an inbound HTTP request 454 | # 455 | proc wappInt-http-readable {chan} { 456 | if {[catch [list wappInt-http-readable-unsafe $chan] msg]} { 457 | puts stderr "$msg\n$::errorInfo" 458 | wappInt-close-channel $chan 459 | } 460 | } 461 | proc wappInt-http-readable-unsafe {chan} { 462 | upvar #0 wappInt-$chan W wapp wapp 463 | if {![dict exists $W .toread]} { 464 | # If the .toread key is not set, that means we are still reading 465 | # the header 466 | set line [string trimright [gets $chan]] 467 | set n [string length $line] 468 | if {$n>0} { 469 | if {[dict get $W .header]=="" || [regexp {^\s+} $line]} { 470 | dict append W .header $line 471 | } else { 472 | dict append W .header \n$line 473 | } 474 | if {[string length [dict get $W .header]]>100000} { 475 | error "HTTP request header too big - possible DOS attack" 476 | } 477 | } elseif {$n==0} { 478 | # We have reached the blank line that terminates the header. 479 | global argv0 480 | if {[info exists ::argv0]} { 481 | set a0 [file normalize $argv0] 482 | } else { 483 | set a0 / 484 | } 485 | dict set W SCRIPT_FILENAME $a0 486 | dict set W DOCUMENT_ROOT [file dir $a0] 487 | if {[wappInt-parse-header $chan]} { 488 | catch {close $chan} 489 | return 490 | } 491 | set len 0 492 | if {[dict exists $W CONTENT_LENGTH]} { 493 | set len [dict get $W CONTENT_LENGTH] 494 | } 495 | if {$len>0} { 496 | # Still need to read the query content 497 | dict set W .toread $len 498 | } else { 499 | # There is no query content, so handle the request immediately 500 | set wapp $W 501 | wappInt-handle-request $chan 502 | } 503 | } 504 | } else { 505 | # If .toread is set, that means we are reading the query content. 506 | # Continue reading until .toread reaches zero. 507 | set got [read $chan [dict get $W .toread]] 508 | dict append W CONTENT $got 509 | dict set W .toread [expr {[dict get $W .toread]-[string length $got]}] 510 | if {[dict get $W .toread]<=0} { 511 | # Handle the request as soon as all the query content is received 512 | set wapp $W 513 | wappInt-handle-request $chan 514 | } 515 | } 516 | } 517 | 518 | # Decode the HTTP request header. 519 | # 520 | # This routine is always running inside of a [catch], so if 521 | # any problems arise, simply raise an error. 522 | # 523 | proc wappInt-parse-header {chan} { 524 | upvar #0 wappInt-$chan W 525 | set hdr [split [dict get $W .header] \n] 526 | if {$hdr==""} {return 1} 527 | set req [lindex $hdr 0] 528 | dict set W REQUEST_METHOD [set method [lindex $req 0]] 529 | if {[lsearch {GET HEAD POST} $method]<0} { 530 | error "unsupported request method: \"[dict get $W REQUEST_METHOD]\"" 531 | } 532 | set uri [lindex $req 1] 533 | dict set W REQUEST_URI $uri 534 | set split_uri [split $uri ?] 535 | set uri0 [lindex $split_uri 0] 536 | if {![regexp {^/[-.a-z0-9_/]*$} $uri0]} { 537 | regsub -all {[-.a-z0-9_/]+} $uri0 {} bad 538 | error "disallowed character(s) \"$bad\" in request uri: \"$uri0\"" 539 | } 540 | dict set W PATH_INFO $uri0 541 | set uri1 [lindex $split_uri 1] 542 | dict set W QUERY_STRING $uri1 543 | set n [llength $hdr] 544 | for {set i 1} {$i<$n} {incr i} { 545 | set x [lindex $hdr $i] 546 | if {![regexp {^(.+): +(.*)$} $x all name value]} { 547 | error "invalid header line: \"$x\"" 548 | } 549 | set name [string toupper $name] 550 | switch -- $name { 551 | REFERER {set name HTTP_REFERER} 552 | USER-AGENT {set name HTTP_USER_AGENT} 553 | CONTENT-LENGTH {set name CONTENT_LENGTH} 554 | CONTENT-TYPE {set name CONTENT_TYPE} 555 | HOST {set name HTTP_HOST} 556 | COOKIE {set name HTTP_COOKIE} 557 | ACCEPT-ENCODING {set name HTTP_ACCEPT_ENCODING} 558 | default {set name .hdr:$name} 559 | } 560 | dict set W $name $value 561 | } 562 | return 0 563 | } 564 | 565 | # Decode the QUERY_STRING parameters from a GET request or the 566 | # application/x-www-form-urlencoded CONTENT from a POST request. 567 | # 568 | # This routine sets the ".qp" element of the ::wapp dict as a signal 569 | # that query parameters have already been decoded. 570 | # 571 | proc wappInt-decode-query-params {} { 572 | global wapp 573 | dict set wapp .qp 1 574 | if {[dict exists $wapp QUERY_STRING]} { 575 | foreach qterm [split [dict get $wapp QUERY_STRING] &] { 576 | set qsplit [split $qterm =] 577 | set nm [lindex $qsplit 0] 578 | if {[regexp {^[a-z][a-z0-9]*$} $nm]} { 579 | dict set wapp $nm [wappInt-decode-url [lindex $qsplit 1]] 580 | } 581 | } 582 | } 583 | if {[dict exists $wapp CONTENT_TYPE] && [dict exists $wapp CONTENT]} { 584 | set ctype [dict get $wapp CONTENT_TYPE] 585 | if {$ctype=="application/x-www-form-urlencoded"} { 586 | foreach qterm [split [string trim [dict get $wapp CONTENT]] &] { 587 | set qsplit [split $qterm =] 588 | set nm [lindex $qsplit 0] 589 | if {[regexp {^[a-z][-a-z0-9_]*$} $nm]} { 590 | dict set wapp $nm [wappInt-decode-url [lindex $qsplit 1]] 591 | } 592 | } 593 | } elseif {[string match multipart/form-data* $ctype]} { 594 | regexp {^(.*?)\n(.*)$} [dict get $wapp CONTENT] all divider body 595 | set ndiv [string length $divider] 596 | while {[string length $body]} { 597 | set idx [string first $divider $body] 598 | set unit [string range $body 0 [expr {$idx-3}]] 599 | set body [string range $body [expr {$idx+$ndiv+2}] end] 600 | if {[regexp {^Content-Disposition: form-data; (.*?)\n\r?\n(.*)$} \ 601 | $unit unit hdr content]} { 602 | if {[regexp {name="(.*)"; filename="(.*)"\r?\nContent-Type: (.*?)$}\ 603 | $hdr hr name filename mimetype] 604 | && [regexp {^[a-z][a-z0-9]*$} $name]} { 605 | dict set wapp $name.filename \ 606 | [string map [list \\\" \" \\\\ \\] $filename] 607 | dict set wapp $name.mimetype $mimetype 608 | dict set wapp $name.content $content 609 | } elseif {[regexp {name="(.*)"} $hdr hr name] 610 | && [regexp {^[a-z][a-z0-9]*$} $name]} { 611 | dict set wapp $name $content 612 | } 613 | } 614 | } 615 | } 616 | } 617 | } 618 | 619 | # Invoke application-supplied methods to generate a reply to 620 | # a single HTTP request. 621 | # 622 | # This routine uses the global variable ::wapp and so must not be nested. 623 | # It must run to completion before the next instance runs. If a recursive 624 | # instances of this routine starts while another is running, the the 625 | # recursive instance is added to a queue to be invoked after the current 626 | # instance finishes. Yes, this means that WAPP IS SINGLE THREADED. Only 627 | # a single page rendering instance my be running at a time. There can 628 | # be multiple HTTP requests inbound at once, but only one my be processed 629 | # at a time once the request is full read and parsed. 630 | # 631 | set wappIntPending {} 632 | set wappIntLock 0 633 | proc wappInt-handle-request {chan} { 634 | global wappIntPending wappIntLock 635 | fileevent $chan readable {} 636 | if {$wappIntLock} { 637 | # Another instance of request is already running, so defer this one 638 | lappend wappIntPending [list wappInt-handle-request $chan] 639 | return 640 | } 641 | set wappIntLock 1 642 | catch [list wappInt-handle-request-unsafe $chan] 643 | set wappIntLock 0 644 | if {[llength $wappIntPending]>0} { 645 | # If there are deferred requests, then launch the oldest one 646 | after idle [lindex $wappIntPending 0] 647 | set wappIntPending [lrange $wappIntPending 1 end] 648 | } 649 | } 650 | proc wappInt-handle-request-unsafe {chan} { 651 | global wapp 652 | dict set wapp .reply {} 653 | dict set wapp .mimetype {text/html; charset=utf-8} 654 | dict set wapp .reply-code {200 Ok} 655 | dict set wapp .csp {default-src 'self'} 656 | 657 | # Set up additional CGI environment values 658 | # 659 | if {![dict exists $wapp HTTP_HOST]} { 660 | dict set wapp BASE_URL {} 661 | } elseif {[dict exists $wapp HTTPS]} { 662 | dict set wapp BASE_URL https://[dict get $wapp HTTP_HOST] 663 | } else { 664 | dict set wapp BASE_URL http://[dict get $wapp HTTP_HOST] 665 | } 666 | if {![dict exists $wapp REQUEST_URI]} { 667 | dict set wapp REQUEST_URI / 668 | } 669 | if {[dict exists $wapp SCRIPT_NAME]} { 670 | dict append wapp BASE_URL [dict get $wapp SCRIPT_NAME] 671 | } else { 672 | dict set wapp SCRIPT_NAME {} 673 | } 674 | if {![dict exists $wapp PATH_INFO]} { 675 | # If PATH_INFO is missing (ex: nginx) then construct it 676 | set URI [dict get $wapp REQUEST_URI] 677 | regsub {\?.*} $URI {} URI 678 | set skip [string length [dict get $wapp SCRIPT_NAME]] 679 | dict set wapp PATH_INFO [string range $URI $skip end] 680 | } 681 | if {[regexp {^/([^/]+)(.*)$} [dict get $wapp PATH_INFO] all head tail]} { 682 | dict set wapp PATH_HEAD $head 683 | dict set wapp PATH_TAIL [string trimleft $tail /] 684 | } else { 685 | dict set wapp PATH_INFO {} 686 | dict set wapp PATH_HEAD {} 687 | dict set wapp PATH_TAIL {} 688 | } 689 | dict set wapp SELF_URL [dict get $wapp BASE_URL]/[dict get $wapp PATH_HEAD] 690 | 691 | # Parse query parameters from the query string, the cookies, and 692 | # POST data 693 | # 694 | if {[dict exists $wapp HTTP_COOKIE]} { 695 | foreach qterm [split [dict get $wapp HTTP_COOKIE] {;}] { 696 | set qsplit [split [string trim $qterm] =] 697 | set nm [lindex $qsplit 0] 698 | if {[regexp {^[a-z][-a-z0-9_]*$} $nm]} { 699 | dict set wapp $nm [wappInt-decode-url [lindex $qsplit 1]] 700 | } 701 | } 702 | } 703 | set same_origin 0 704 | if {[dict exists $wapp HTTP_REFERER]} { 705 | set referer [dict get $wapp HTTP_REFERER] 706 | set base [dict get $wapp BASE_URL] 707 | if {$referer==$base || [string match $base/* $referer]} { 708 | set same_origin 1 709 | } 710 | } 711 | dict set wapp SAME_ORIGIN $same_origin 712 | if {$same_origin} { 713 | wappInt-decode-query-params 714 | } 715 | 716 | # Invoke the application-defined handler procedure for this page 717 | # request. If an error occurs while running that procedure, generate 718 | # an HTTP reply that contains the error message. 719 | # 720 | wapp-before-dispatch-hook 721 | wappInt-trace 722 | set mname [dict get $wapp PATH_HEAD] 723 | if {[catch { 724 | if {$mname!="" && [llength [info command wapp-page-$mname]]>0} { 725 | wapp-page-$mname 726 | } else { 727 | wapp-default 728 | } 729 | } msg]} { 730 | if {[wapp-param WAPP_MODE]=="local" || [wapp-param WAPP_MODE]=="server"} { 731 | puts "ERROR: $::errorInfo" 732 | } 733 | wapp-reset 734 | if {[info command wapp-crash-handler]=="" 735 | || [catch wapp-crash-handler]} { 736 | wapp-reply-code "500 Internal Server Error" 737 | wapp-mimetype text/html 738 | wapp-trim { 739 |

Wapp Application Error

740 |
%html($::errorInfo)
741 | } 742 | } 743 | dict unset wapp .new-cookies 744 | } 745 | wapp-before-reply-hook 746 | 747 | # Transmit the HTTP reply 748 | # 749 | set rc [dict get $wapp .reply-code] 750 | if {$rc=="ABORT"} { 751 | # If the page handler invokes "wapp-reply-code ABORT" then close the 752 | # TCP/IP connection without sending any reply 753 | wappInt-close-channel $chan 754 | return 755 | } elseif {$chan=="stdout"} { 756 | puts $chan "Status: $rc\r" 757 | } else { 758 | puts $chan "HTTP/1.1 $rc\r" 759 | puts $chan "Server: wapp\r" 760 | puts $chan "Connection: close\r" 761 | } 762 | if {[dict exists $wapp .reply-extra]} { 763 | foreach {name value} [dict get $wapp .reply-extra] { 764 | puts $chan "$name: $value\r" 765 | } 766 | } 767 | if {[dict exists $wapp .csp]} { 768 | set csp [dict get $wapp .csp] 769 | regsub {\n} [string trim $csp] { } csp 770 | puts $chan "Content-Security-Policy: $csp\r" 771 | } 772 | set mimetype [dict get $wapp .mimetype] 773 | puts $chan "Content-Type: $mimetype\r" 774 | if {[dict exists $wapp .new-cookies]} { 775 | foreach {nm val} [dict get $wapp .new-cookies] { 776 | if {[regexp {^[a-z][-a-z0-9_]*$} $nm]} { 777 | if {$val==""} { 778 | puts $chan "Set-Cookie: $nm=; HttpOnly; Path=/; Max-Age=1\r" 779 | } else { 780 | set val [wappInt-enc-url $val] 781 | puts $chan "Set-Cookie: $nm=$val; HttpOnly; Path=/\r" 782 | } 783 | } 784 | } 785 | } 786 | if {[string match text/* $mimetype]} { 787 | set reply [encoding convertto utf-8 [dict get $wapp .reply]] 788 | if {[regexp {\ygzip\y} [wapp-param HTTP_ACCEPT_ENCODING]]} { 789 | catch {wappInt-gzip-reply reply chan} 790 | } 791 | } else { 792 | set reply [dict get $wapp .reply] 793 | } 794 | puts $chan "Content-Length: [string length $reply]\r" 795 | puts $chan \r 796 | puts -nonewline $chan $reply 797 | flush $chan 798 | wappInt-close-channel $chan 799 | } 800 | 801 | # Compress the reply content 802 | # 803 | proc wappInt-gzip-reply {replyVar chanVar} { 804 | upvar $replyVar reply $chanVar chan 805 | set x [zlib gzip $reply] 806 | set reply $x 807 | puts $chan "Content-Encoding: gzip\r" 808 | } 809 | 810 | # This routine runs just prior to request-handler dispatch. The 811 | # default implementation is a no-op, but applications can override 812 | # to do additional transformations or checks. 813 | # 814 | proc wapp-before-dispatch-hook {} {return} 815 | 816 | # This routine runs after the request-handler dispatch and just 817 | # before the reply is generated. The default implementation is 818 | # a no-op, but applications can override to do validation and security 819 | # checks on the reply, such as verifying that no sensitive information 820 | # such as an API key or password is accidentally included in the 821 | # reply text. 822 | # 823 | proc wapp-before-reply-hook {} {return} 824 | 825 | # Process a single CGI request 826 | # 827 | proc wappInt-handle-cgi-request {} { 828 | global wapp env 829 | foreach key [array names env {[A-Z]*}] {dict set wapp $key $env($key)} 830 | set len 0 831 | if {[dict exists $wapp CONTENT_LENGTH]} { 832 | set len [dict get $wapp CONTENT_LENGTH] 833 | } 834 | if {$len>0} { 835 | fconfigure stdin -translation binary 836 | dict set wapp CONTENT [read stdin $len] 837 | } 838 | dict set wapp WAPP_MODE cgi 839 | fconfigure stdout -translation binary 840 | wappInt-handle-request-unsafe stdout 841 | } 842 | 843 | # Process new text received on an inbound SCGI request 844 | # 845 | proc wappInt-scgi-readable {chan} { 846 | if {[catch [list wappInt-scgi-readable-unsafe $chan] msg]} { 847 | puts stderr "$msg\n$::errorInfo" 848 | wappInt-close-channel $chan 849 | } 850 | } 851 | proc wappInt-scgi-readable-unsafe {chan} { 852 | upvar #0 wappInt-$chan W wapp wapp 853 | if {![dict exists $W .toread]} { 854 | # If the .toread key is not set, that means we are still reading 855 | # the header. 856 | # 857 | # An SGI header is short. This implementation assumes the entire 858 | # header is available all at once. 859 | # 860 | dict set W .remove_addr [dict get $W REMOTE_ADDR] 861 | set req [read $chan 15] 862 | set n [string length $req] 863 | scan $req %d:%s len hdr 864 | incr len [string length "$len:,"] 865 | append hdr [read $chan [expr {$len-15}]] 866 | foreach {nm val} [split $hdr \000] { 867 | if {$nm==","} break 868 | dict set W $nm $val 869 | } 870 | set len 0 871 | if {[dict exists $W CONTENT_LENGTH]} { 872 | set len [dict get $W CONTENT_LENGTH] 873 | } 874 | if {$len>0} { 875 | # Still need to read the query content 876 | dict set W .toread $len 877 | } else { 878 | # There is no query content, so handle the request immediately 879 | dict set W SERVER_ADDR [dict get $W .remove_addr] 880 | set wapp $W 881 | wappInt-handle-request $chan 882 | } 883 | } else { 884 | # If .toread is set, that means we are reading the query content. 885 | # Continue reading until .toread reaches zero. 886 | set got [read $chan [dict get $W .toread]] 887 | dict append W CONTENT $got 888 | dict set W .toread [expr {[dict get $W .toread]-[string length $got]}] 889 | if {[dict get $W .toread]<=0} { 890 | # Handle the request as soon as all the query content is received 891 | dict set W SERVER_ADDR [dict get $W .remove_addr] 892 | set wapp $W 893 | wappInt-handle-request $chan 894 | } 895 | } 896 | } 897 | 898 | # Start up the wapp framework. Parameters are a list passed as the 899 | # single argument. 900 | # 901 | # -server $PORT Listen for HTTP requests on this TCP port $PORT 902 | # 903 | # -local $PORT Listen for HTTP requests on 127.0.0.1:$PORT 904 | # 905 | # -scgi $PORT Listen for SCGI requests on 127.0.0.1:$PORT 906 | # 907 | # -remote-scgi $PORT Listen for SCGI requests on TCP port $PORT 908 | # 909 | # -cgi Handle a single CGI request 910 | # 911 | # With no arguments, the behavior is called "auto". In "auto" mode, 912 | # if the GATEWAY_INTERFACE environment variable indicates CGI, then run 913 | # as CGI. Otherwise, start an HTTP server bound to the loopback address 914 | # only, on an arbitrary TCP port, and automatically launch a web browser 915 | # on that TCP port. 916 | # 917 | # Additional options: 918 | # 919 | # -fromip GLOB Reject any incoming request where the remote 920 | # IP address does not match the GLOB pattern. This 921 | # value defaults to '127.0.0.1' for -local and -scgi. 922 | # 923 | # -nowait Do not wait in the event loop. Return immediately 924 | # after all event handlers are established. 925 | # 926 | # -trace "puts" each request URL as it is handled, for 927 | # debugging 928 | # 929 | # -debug Disable content compression 930 | # 931 | # -lint Run wapp-safety-check on the application instead 932 | # of running the application itself 933 | # 934 | # -Dvar=value Set TCL global variable "var" to "value" 935 | # 936 | # 937 | proc wapp-start {arglist} { 938 | global env 939 | set mode auto 940 | set port 0 941 | set nowait 0 942 | set fromip {} 943 | set n [llength $arglist] 944 | for {set i 0} {$i<$n} {incr i} { 945 | set term [lindex $arglist $i] 946 | if {[string match --* $term]} {set term [string range $term 1 end]} 947 | switch -glob -- $term { 948 | -server { 949 | incr i; 950 | set mode "server" 951 | set port [lindex $arglist $i] 952 | } 953 | -local { 954 | incr i; 955 | set mode "local" 956 | set fromip 127.0.0.1 957 | set port [lindex $arglist $i] 958 | } 959 | -scgi { 960 | incr i; 961 | set mode "scgi" 962 | set fromip 127.0.0.1 963 | set port [lindex $arglist $i] 964 | } 965 | -remote-scgi { 966 | incr i; 967 | set mode "remote-scgi" 968 | set port [lindex $arglist $i] 969 | } 970 | -cgi { 971 | set mode "cgi" 972 | } 973 | -fromip { 974 | incr i 975 | set fromip [lindex $arglist $i] 976 | } 977 | -nowait { 978 | set nowait 1 979 | } 980 | -debug { 981 | proc wappInt-gzip-reply {a b} {return} 982 | } 983 | -trace { 984 | proc wappInt-trace {} { 985 | set q [wapp-param QUERY_STRING] 986 | set uri [wapp-param BASE_URL][wapp-param PATH_INFO] 987 | if {$q!=""} {append uri ?$q} 988 | puts $uri 989 | } 990 | } 991 | -lint { 992 | set res [wapp-safety-check] 993 | if {$res!=""} { 994 | puts "Potential problems in this code:" 995 | puts $res 996 | exit 1 997 | } else { 998 | exit 999 | } 1000 | } 1001 | -D*=* { 1002 | if {[regexp {^.D([^=]+)=(.*)$} $term all var val]} { 1003 | set ::$var $val 1004 | } 1005 | } 1006 | default { 1007 | error "unknown option: $term" 1008 | } 1009 | } 1010 | } 1011 | if {$mode=="auto"} { 1012 | if {[info exists env(GATEWAY_INTERFACE)] 1013 | && [string match CGI/1.* $env(GATEWAY_INTERFACE)]} { 1014 | set mode cgi 1015 | } else { 1016 | set mode local 1017 | } 1018 | } 1019 | if {$mode=="cgi"} { 1020 | wappInt-handle-cgi-request 1021 | } else { 1022 | wappInt-start-listener $port $mode $fromip 1023 | if {!$nowait} { 1024 | vwait ::forever 1025 | } 1026 | } 1027 | } 1028 | 1029 | # Call this version 1.0 1030 | package provide wapp 1.0 1031 | --------------------------------------------------------------------------------