├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── Makefile ├── README.md ├── data └── keymaps │ └── en-US_qwerty.yml ├── docs └── panopticon.png ├── shard.lock ├── shard.yml ├── spec ├── fincher_spec.cr ├── io_scanner_spec.cr ├── spec_helper.cr ├── strategies │ ├── displacement │ │ ├── m_word_offset_spec.cr │ │ ├── matching_char_offset_spec.cr │ │ └── n_char_offset_spec.cr │ └── replacement │ │ ├── keymap_spec.cr │ │ └── n_shifter_spec.cr └── transformer_spec.cr └── src ├── cli.cr ├── fincher.cr └── fincher ├── cli.cr ├── embedded_fs.cr ├── errors.cr ├── io_scanner.cr ├── strategies ├── displacement │ ├── base.cr │ ├── m_word_offset.cr │ ├── matching_char_offset.cr │ └── n_char_offset.cr ├── replacement │ ├── base.cr │ ├── keymap.cr │ └── n_shifter.cr └── strategies.cr ├── transformer.cr ├── types.cr ├── types ├── keymap.cr └── keymap_entry.cr └── version.cr /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | - '*.md' 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - uses: crystal-lang/install-crystal@v1 18 | with: 19 | crystal: 1.10.0 20 | 21 | - name: Install dependencies 22 | run: shards install 23 | 24 | - name: Run tests 25 | run: make test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /bin/ 4 | /dist/ 5 | /.shards/ 6 | /test_files/ 7 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | crystal 1.10.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Max Fierke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CRYSTAL ?= $(shell which crystal) 2 | SHARDS ?= $(shell which shards) 3 | FINCHER ?= $(shell which fincher) 4 | PREFIX ?= /usr/local 5 | RELEASE ?= 6 | STATIC ?= 7 | SOURCES = src/*.cr src/**/*.cr 8 | 9 | override CRFLAGS += -Duse_pcre2 --warnings=all --error-trace $(if $(RELEASE),--release ,--debug )$(if $(STATIC),--static )$(if $(LDFLAGS),--link-flags="$(LDFLAGS)" ) 10 | 11 | .PHONY: all 12 | all: build 13 | 14 | bin/fincher: deps $(SOURCES) 15 | mkdir -p bin 16 | $(CRYSTAL) build -o bin/fincher src/cli.cr $(CRFLAGS) 17 | 18 | .PHONY: build 19 | build: bin/fincher 20 | 21 | .PHONY: deps 22 | deps: 23 | $(SHARDS) check || $(SHARDS) install 24 | 25 | .PHONY: clean 26 | clean: 27 | rm -f ./bin/fincher* 28 | rm -rf ./dist 29 | 30 | .PHONY: test 31 | test: deps $(SOURCES) 32 | $(CRYSTAL) spec $(CRFLAGS) 33 | 34 | .PHONY: spec 35 | spec: test 36 | 37 | .PHONY: install 38 | install: bin/fincher 39 | mkdir -p $(PREFIX)/bin 40 | cp ./bin/fincher $(PREFIX)/bin 41 | 42 | .PHONY: reinstall 43 | reinstall: bin/fincher 44 | cp ./bin/fincher $(FINCHER) -rf 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fincher 2 | 3 | fincher is steganography tool for text. It provides a number of strategies for 4 | hiding a message within a source text by storing each character as a typo. 5 | 6 | The method by which it works is contigent upon the combination of replacement 7 | and displacement strategy. See [Usage](#Usage) for more information. 8 | 9 | ![Still from Person of Interest episode "Panopticon", Season 4 Episode 1](docs/panopticon.png) 10 | 11 | The inspiration for `fincher` comes from "Panopticon", Season 4 Episode 1 in 12 | Person of Interest, in which _The Machine_ encodes a message as typos in the 13 | dissertation of one of the main characters, Harold Finch. 14 | 15 | `fincher` is currently `0.2.2` and considered an **experiment** 16 | and a project for **funsies**. I am very interested in contributions & ideas! 17 | 18 | ## Disclaimer 19 | 20 | While `fincher` is a steganography tool, **no guarantees are made about it's 21 | suitablity for any purpose, especially hiding information from hostile actors**. 22 | 23 | Due to the fact that fincher hides messages in a source text as typos, if the 24 | information is stored digitally as text, it would be relatively easy to 25 | run a spellchecking over the text to determine where the typos are, and work 26 | backwards. Possible mitigations are storing text in physical printed form and 27 | encrypting the source message. 28 | 29 | ## Installation 30 | 31 | ### via Homebrew (macOS users) 32 | 33 | ``` 34 | $ brew tap maxfierke/fincher 35 | $ brew install fincher 36 | ``` 37 | 38 | ### Manually 39 | 40 | 1. Ensure you have the [crystal compiler installed](https://crystal-lang.org/docs/installation/) (1.7.0+) 41 | 2. Clone this repo 42 | 3. Run `make install RELEASE=1` to build for release mode and install 43 | 4. `fincher` will be installed to `/usr/local/bin` and usable anywhere, provided it's in your `PATH`. 44 | 45 | ## Usage 46 | 47 | ``` 48 | $ fincher encode 49 | 50 | fincher encode [OPTIONS] SOURCE_TEXT_FILE MESSAGE 51 | 52 | Arguments: 53 | MESSAGE message 54 | SOURCE_TEXT_FILE source text file 55 | 56 | Options: 57 | --char-offset NUMBER character gap between typos (Displacement Strategies: char-offset) 58 | (default: 130) 59 | --codepoint-shift NUMBER codepoints to shift (Replacement Strategies: n-shifter) 60 | (default: 7) 61 | --displacement-strategy STRING displacement strategy (Options: char-offset, word-offset, matching-char-offset) 62 | (default: matching-char-offset) 63 | --keymap STRING Keymap definition to use for keymap replacement strategy 64 | (default: en-US_qwerty) 65 | --replacement-strategy STRING replacement strategy (Options: n-shifter, keymap) 66 | (default: keymap) 67 | --seed NUMBER seed value. randomly generated if omitted 68 | (default: ) 69 | --word-offset NUMBER word gap between typos (Displacement Strategies: word-offset, matching-char-offset) 70 | (default: 38) 71 | ``` 72 | 73 | ### Example 74 | 75 | Let's use the part of the introduction paragraph of the [English Wikipedia article for Canada](https://en.wikipedia.org/wiki/Canada) 76 | 77 | > Canada is a country in the northern part of North America. Its ten provinces 78 | > and three territories extend from the Atlantic to the Pacific and northward 79 | > into the Arctic Ocean, covering 9.98 million square kilometres (3.85 million 80 | > square miles), making it the world's second-largest country by total area. 81 | 82 | This is saved in `test_files/canada.txt`. 83 | 84 | Next, we'll encode it with `fincher`. 85 | 86 | ``` 87 | $ fincher encode --displacement-strategy word-offset --word-offset 3 --replacement-strategy n-shifter --codepoint-shift 0 test_files/canada.txt "Hello GitHub" 88 | ``` 89 | 90 | Which will produce this output: 91 | 92 | > Canada is a **H**ountry in the **e**orthern part of **l**orth America. Its **l**en provinces and 93 | > **o**hree territories extend **\_**rom the Atlantic **G**o the Pacific **i**nd northward into **t**he 94 | > Arctic Ocean, **H**overing 9.98 **u**illion square kilometres (**b**.85 million square miles 95 | > ), making it the world's second-largest country by total area. 96 | 97 | 98 | ### Displacement strategies 99 | 100 | Displacement strategies determine where each character within the message gets 101 | encoded within the source text. 102 | 103 | #### `char-offset` 104 | 105 | The `char-offset` strategy will distribute each message character by N number of 106 | characters, as specified by the `--char-offset` option. 107 | 108 | e.g. `--displacement-strategy char-offset --char-offset 10` will 109 | distribute a character of the message every 10 characters in the source text. 110 | 111 | **Relevant options**: `--char-offset` 112 | 113 | #### `matching-char-offset` 114 | 115 | The `matching-char-offset` strategy will distribute each message character by 116 | finding a matching character at least every N words, as specified by the 117 | `--word-offset` option. 118 | 119 | e.g. `--displacement-strategy matching-char-offset --word-offset 10` 120 | will take a message character and ensure there's _at least_ a 10 word gap 121 | since the last message character then find the next matching character in the 122 | source text. 123 | 124 | **Relevant options**: `--word-offset` 125 | 126 | #### `word-offset` 127 | 128 | The `word-offset` strategy will distribute each message character by N number of 129 | words, as specified by the `--word-offset` option. 130 | 131 | e.g. `--displacement-strategy char-offset --word-offset 10` will 132 | distribute a character of the message every 10 words in the source text. 133 | 134 | **Relevant options**: `--word-offset` 135 | 136 | ### Replacement strategies 137 | 138 | Replacement strategies determine how a character within the source text is 139 | replaced, based on an individual message character. 140 | 141 | #### `keymap` 142 | 143 | The `keymap` strategy will replace a character within the source text based on 144 | a keymap definition of which keys neighbor it (including Shift modified). The 145 | key chosen will be random. 146 | 147 | Which keymap to use can be specified by the `--keymap` option, 148 | e.g. `--keymap en-US_qwerty`, but is of little use right now, as only 149 | `en-US_qwerty` is supported. 150 | 151 | `keymap` is best paired with the `matching-char-offset` replacement strategy to 152 | create an effect of a plausible typo. 153 | 154 | **Relevant options**: `--keymap`, `--seed` 155 | 156 | #### `n-shifter` 157 | 158 | The `n-shifter` strategy will replace a character within the source text with 159 | a message character shifted N codepoints, as specified by the `--codepoint-shift` 160 | option. 161 | 162 | **Relevant options**: `--codepoint-shift` 163 | 164 | ## Decoding 165 | 166 | You may have noticed that there is no `fincher decode` command. Partly, this is 167 | is because the intention is that the typos are to be resolved by a human reading 168 | the encoded text. However, it is also the case that many of the displacement and 169 | replacement strategy combinations are non-deterministic and potentially lossy. 170 | 171 | For example, the `keymap` replacement strategy will (pseudo)randomly decide 172 | which character to use to replace a character in the source text based on the 173 | characters close to a message character on the keyboard. 174 | 175 | ## Limitations 176 | 177 | `fincher` is early stages and has some notable limitations: 178 | 179 | * The current displacement and replacement strategies are not context-aware. 180 | i.e. they do not make judgements based on the content of the source text and 181 | whether the replacement or displacement makes sense grammatically. This will 182 | probably change. 183 | * Source text scanning (rightly or wrongly) happens on a rotating 184 | 4K buffer (so you could feed it multi-GB source text, if you wanted to) and 185 | the `IOScanner` does not handle regex matching across buffer boundaries. 186 | Therefore, the `--[word|char]-offset` parameters are not applied exactly, but 187 | will make minimum guarantees about the offset. 188 | * Does not yet take input from `STDIN`, so it cannot be piped to yet. (It does 189 | however, output to `STDOUT`.) 190 | 191 | ## Development 192 | 193 | To work on `fincher`, you'll need a current version of the Crystal compiler. I 194 | generally try to keep it targeting the latest version, as Crystal is a moving 195 | target, and not all APIs have stability guarantees yet. 196 | 197 | I welcome suggestion and discussion of new displacement and replacement 198 | strategies, as well as architectural and interface changes. 199 | 200 | ## Contributing 201 | 202 | 1. Fork it ( https://github.com/maxfierke/fincher/fork ) 203 | 2. Create your feature branch (git checkout -b my-new-feature) 204 | 3. Commit your changes (git commit -am 'Add some feature') 205 | 4. Push to the branch (git push origin my-new-feature) 206 | 5. Create a new Pull Request 207 | 208 | ## Contributors 209 | 210 | - [maxfierke](https://github.com/maxfierke) Max Fierke - creator, maintainer 211 | -------------------------------------------------------------------------------- /data/keymaps/en-US_qwerty.yml: -------------------------------------------------------------------------------- 1 | --- 2 | data: 3 | '`': 4 | shift: '~' 5 | neighbors: ['1'] 6 | '1': 7 | shift: '!' 8 | neighbors: ['`', 'q'] 9 | '2': 10 | shift: '@' 11 | neighbors: ['1', 'q', '3'] 12 | '3': 13 | shift: '#' 14 | neighbors: ['2', 'w', 'e', '4'] 15 | '4': 16 | shift: '$' 17 | neighbors: ['3', 'e', 'r', '5'] 18 | '5': 19 | shift: '%' 20 | neighbors: ['4', 'r', 't', '6'] 21 | '6': 22 | shift: '^' 23 | neighbors: ['5', 't', 'y', '7'] 24 | '7': 25 | shift: '&' 26 | neighbors: ['6', 'y', 'u', '8'] 27 | '8': 28 | shift: '*' 29 | neighbors: ['7', 'u', 'i', '9'] 30 | '9': 31 | shift: '(' 32 | neighbors: ['8', 'i', 'o', '0'] 33 | '0': 34 | shift: ')' 35 | neighbors: ['9', 'o', 'p', '-'] 36 | '-': 37 | shift: '_' 38 | neighbors: ['0', 'p', '[', '='] 39 | '=': 40 | shift: '+' 41 | neighbors: ['-', '[', ']'] 42 | q: 43 | shift: 'Q' 44 | neighbors: ['1', '2', 'w', 'a'] 45 | w: 46 | shift: 'W' 47 | neighbors: ['q', '2', '3', 'e', 'a', 's'] 48 | e: 49 | shift: 'E' 50 | neighbors: ['w', '3', '4', 'r', 'd', 's'] 51 | r: 52 | shift: 'R' 53 | neighbors: ['e', '4', '5', 't', 'f', 'd'] 54 | t: 55 | shift: 'T' 56 | neighbors: ['r', '5', '6', 'y', 'g', 'f'] 57 | y: 58 | shift: 'Y' 59 | neighbors: ['t', '6', '7', 'u', 'h', 'g'] 60 | u: 61 | shift: 'U' 62 | neighbors: ['y', '7', '8', 'i', 'j', 'h'] 63 | i: 64 | shift: 'I' 65 | neighbors: ['u', '8', '9', 'o', 'k', 'j'] 66 | o: 67 | shift: 'O' 68 | neighbors: ['i', '9', '0', 'p', 'l', 'k'] 69 | p: 70 | shift: 'P' 71 | neighbors: ['o', '0', '-', '[', ';', 'l'] 72 | '[': 73 | shift: '{' 74 | neighbors: ['p', '-', '=', ']', "'", ';'] 75 | ']': 76 | shift: '}' 77 | neighbors: ['[', '=', '\', "'"] 78 | '\': 79 | shift: '|' 80 | neighbors: [']'] 81 | a: 82 | shift: 'A' 83 | neighbors: ['q', 'w', 's', 'z'] 84 | s: 85 | shift: 'S' 86 | neighbors: ['a', 'w', 'e', 'd', 'x', 'z'] 87 | d: 88 | shift: 'D' 89 | neighbors: ['s', 'e', 'r', 'f', 'c', 'x'] 90 | f: 91 | shift: 'F' 92 | neighbors: ['d', 'r', 't', 'g', 'v', 'c'] 93 | g: 94 | shift: 'G' 95 | neighbors: ['f', 't', 'y', 'h', 'b', 'v'] 96 | h: 97 | shift: 'H' 98 | neighbors: ['g', 'y', 'u', 'j', 'n', 'b'] 99 | j: 100 | shift: 'J' 101 | neighbors: ['h', 'u', 'i', 'k', 'm', 'n'] 102 | k: 103 | shift: 'K' 104 | neighbors: ['j', 'i', 'o', 'l', ',', 'm'] 105 | l: 106 | shift: 'L' 107 | neighbors: ['k', 'o', 'p', ';', '.', ','] 108 | ';': 109 | shift: ':' 110 | neighbors: ['l', 'p', '[', "'", '/', '.'] 111 | "'": 112 | shift: '"' 113 | neighbors: [';', '[', ']', '/'] 114 | z: 115 | shift: 'Z' 116 | neighbors: ['a', 's', 'x'] 117 | x: 118 | shift: 'X' 119 | neighbors: ['z', 's', 'd', 'c'] 120 | c: 121 | shift: 'C' 122 | neighbors: ['x', 'd', 'f', 'v'] 123 | v: 124 | shift: 'V' 125 | neighbors: ['c', 'f', 'g', 'b'] 126 | b: 127 | shift: 'B' 128 | neighbors: ['v', 'g', 'h', 'n'] 129 | n: 130 | shift: 'N' 131 | neighbors: ['b', 'h', 'j', 'm'] 132 | m: 133 | shift: 'M' 134 | neighbors: ['n', 'j', 'k', ','] 135 | ',': 136 | shift: '<' 137 | neighbors: ['m', 'k', 'l', '.'] 138 | '.': 139 | shift: '>' 140 | neighbors: [',', 'l', ';', '/'] 141 | '/': 142 | shift: '?' 143 | neighbors: ['.', ';', "'"] 144 | ' ': 145 | shift: ' ' 146 | neighbors: ['x', 'c', 'v', 'b', 'n', 'm', ','] 147 | -------------------------------------------------------------------------------- /docs/panopticon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxfierke/fincher/d5c405887a5fd905bf0b0c10298f1e592d01a5ef/docs/panopticon.png -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | baked_file_system: 4 | git: https://github.com/schovi/baked_file_system.git 5 | version: 0.9.8+git.commit.7183bfde8d86a976a6912f582b51cbd1783456af 6 | 7 | callback: 8 | git: https://github.com/amberframework/callback.git 9 | version: 0.7.1 10 | 11 | cli: 12 | git: https://github.com/amberframework/cli.git 13 | version: 0.9.3 14 | 15 | optarg: 16 | git: https://github.com/amberframework/optarg.git 17 | version: 0.8.0 18 | 19 | string_inflection: 20 | git: https://github.com/mosop/string_inflection.git 21 | version: 0.2.1 22 | 23 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: fincher 2 | version: 0.2.2 3 | 4 | authors: 5 | - Max Fierke 6 | 7 | dependencies: 8 | baked_file_system: 9 | github: schovi/baked_file_system 10 | branch: master 11 | 12 | cli: 13 | github: amberframework/cli 14 | version: ~> 0.9.3 15 | 16 | targets: 17 | fincher: 18 | main: src/cli.cr 19 | 20 | crystal: ">= 1.8.0" 21 | 22 | license: MIT 23 | -------------------------------------------------------------------------------- /spec/fincher_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Fincher do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/io_scanner_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Fincher::IOScanner, "#scan" do 4 | it "returns the string matched and advances the offset" do 5 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 6 | s.scan(/\w+\s/).should eq("this ") 7 | s.scan(/\w+\s/).should eq("is ") 8 | s.scan(/\w+\s/).should eq("a ") 9 | s.scan(/\w+/).should eq("string") 10 | end 11 | 12 | it "returns nil if it can't match from the offset" do 13 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 14 | s.scan(/\w+/).should_not be_nil # => "test" 15 | s.scan(/\w+/).should be_nil 16 | s.scan(/\s\w+/).should_not be_nil # => " string" 17 | s.scan(/.*/).should_not be_nil # => "" 18 | end 19 | end 20 | 21 | describe Fincher::IOScanner, "#scan_until" do 22 | it "returns the string matched and advances the offset" do 23 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 24 | s.scan_until(/tr/).should eq("test str") 25 | s.offset.should eq(8) 26 | s.scan_until(/g/).should eq("ing") 27 | end 28 | 29 | it "returns nil if it can't match from the offset" do 30 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 31 | s.offset = 8 32 | s.scan_until(/tr/).should be_nil 33 | end 34 | end 35 | 36 | describe Fincher::IOScanner, "#skip" do 37 | it "advances the offset but does not returns the string matched" do 38 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 39 | 40 | s.skip(/\w+\s/).should eq(5) 41 | s.offset.should eq(5) 42 | s[0]?.should_not be_nil 43 | 44 | s.skip(/\d+/).should eq(nil) 45 | s.offset.should eq(5) 46 | 47 | s.skip(/\w+\s/).should eq(3) 48 | s.offset.should eq(8) 49 | 50 | s.skip(/\w+\s/).should eq(2) 51 | s.offset.should eq(10) 52 | 53 | s.skip(/\w+/).should eq(6) 54 | s.offset.should eq(16) 55 | end 56 | end 57 | 58 | describe Fincher::IOScanner, "#skip_until" do 59 | it "advances the offset but does not returns the string matched" do 60 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 61 | 62 | s.skip_until(/not/).should eq(nil) 63 | s.offset.should eq(0) 64 | s[0]?.should be_nil 65 | 66 | s.skip_until(/a\s/).should eq(10) 67 | s.offset.should eq(10) 68 | s[0]?.should_not be_nil 69 | 70 | s.skip_until(/ng/).should eq(6) 71 | s.offset.should eq(16) 72 | end 73 | end 74 | 75 | describe Fincher::IOScanner, "#eos" do 76 | it "it is true when the offset is at the end" do 77 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 78 | s.eos?.should eq(false) 79 | s.skip(/(\w+\s?){4}/) 80 | s.eos?.should eq(true) 81 | end 82 | end 83 | 84 | describe Fincher::IOScanner, "#check" do 85 | it "returns the string matched but does not advances the offset" do 86 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 87 | s.offset = 5 88 | 89 | s.check(/\w+\s/).should eq("is ") 90 | s.offset.should eq(5) 91 | s.check(/\w+\s/).should eq("is ") 92 | s.offset.should eq(5) 93 | end 94 | 95 | it "returns nil if it can't match from the offset" do 96 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 97 | s.check(/\d+/).should be_nil 98 | end 99 | end 100 | 101 | describe Fincher::IOScanner, "#check_until" do 102 | it "returns the string matched and advances the offset" do 103 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 104 | s.check_until(/tr/).should eq("test str") 105 | s.offset.should eq(0) 106 | s.check_until(/g/).should eq("test string") 107 | s.offset.should eq(0) 108 | end 109 | 110 | it "returns nil if it can't match from the offset" do 111 | s = Fincher::IOScanner.new(::IO::Memory.new("test string")) 112 | s.offset = 8 113 | s.check_until(/tr/).should be_nil 114 | end 115 | end 116 | 117 | describe Fincher::IOScanner, "#rest" do 118 | it "returns the remainder of the string from the offset" do 119 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 120 | s.rest.should eq("this is a string") 121 | 122 | s.scan(/this is a /) 123 | s.rest.should eq("string") 124 | 125 | s.scan(/string/) 126 | s.rest.should eq("") 127 | end 128 | end 129 | 130 | describe Fincher::IOScanner, "#gets_to_end" do 131 | it "returns the remainder of the string from the offset" do 132 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 133 | s.rest.should eq("this is a string") 134 | 135 | s.scan(/this is a /) 136 | s.rest.should eq("string") 137 | 138 | s.scan(/string/) 139 | s.rest.should eq("") 140 | end 141 | end 142 | 143 | describe Fincher::IOScanner, "#[]" do 144 | it "allows access to subgroups of the last match" do 145 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 146 | regex = /(?\w+) (?\w+) (?\d+)/ 147 | s.scan(regex).should eq("Fri Dec 12") 148 | s[0].should eq("Fri Dec 12") 149 | s[1].should eq("Fri") 150 | s[2].should eq("Dec") 151 | s[3].should eq("12") 152 | s["wday"].should eq("Fri") 153 | s["month"].should eq("Dec") 154 | s["day"].should eq("12") 155 | end 156 | 157 | it "raises when there is no last match" do 158 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 159 | s.scan(/this is not there/) 160 | 161 | expect_raises(Exception, "Nil assertion failed") { s[0] } 162 | end 163 | 164 | it "raises when there is no subgroup" do 165 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 166 | regex = /(?\w+) (?\w+) (?\d+)/ 167 | s.scan(regex) 168 | 169 | s[0].should_not be_nil 170 | expect_raises(IndexError) { s[5] } 171 | expect_raises(KeyError, "Capture group 'something' does not exist") { s["something"] } 172 | end 173 | end 174 | 175 | describe Fincher::IOScanner, "#[]?" do 176 | it "allows access to subgroups of the last match" do 177 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 178 | result = s.scan(/(?\w+) (?\w+) (?\d+)/) 179 | 180 | result.should eq("Fri Dec 12") 181 | s[0]?.should eq("Fri Dec 12") 182 | s[1]?.should eq("Fri") 183 | s[2]?.should eq("Dec") 184 | s[3]?.should eq("12") 185 | s["wday"]?.should eq("Fri") 186 | s["month"]?.should eq("Dec") 187 | s["day"]?.should eq("12") 188 | end 189 | 190 | it "returns nil when there is no last match" do 191 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 192 | s.scan(/this is not there/) 193 | 194 | s[0]?.should be_nil 195 | end 196 | 197 | it "raises when there is no subgroup" do 198 | s = Fincher::IOScanner.new(::IO::Memory.new("Fri Dec 12 1975 14:39")) 199 | s.scan(/(?\w+) (?\w+) (?\d+)/) 200 | 201 | s[0].should_not be_nil 202 | s[5]?.should be_nil 203 | s["something"]?.should be_nil 204 | end 205 | end 206 | 207 | describe Fincher::IOScanner, "#string" do 208 | it { Fincher::IOScanner.new(::IO::Memory.new("foo")).string.should eq("foo") } 209 | end 210 | 211 | describe Fincher::IOScanner, "#offset" do 212 | it "returns the current position" do 213 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 214 | s.offset.should eq(0) 215 | s.scan(/\w+/) 216 | s.offset.should eq(4) 217 | end 218 | end 219 | 220 | describe Fincher::IOScanner, "#offset=" do 221 | it "sets the current position" do 222 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 223 | s.offset = 5 224 | s.scan(/\w+/).should eq("is") 225 | end 226 | 227 | it "raises on negative positions" do 228 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 229 | expect_raises(IndexError) { s.offset = -2 } 230 | end 231 | end 232 | 233 | describe Fincher::IOScanner, "#inspect" do 234 | it "has information on the scanner" do 235 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 236 | s.inspect.should eq(%(#)) 237 | s.scan(/\w+\s/) 238 | s.inspect.should eq(%(#)) 239 | s.scan(/\w+\s/) 240 | s.inspect.should eq(%(#)) 241 | s.scan(/\w+\s\w+/) 242 | s.inspect.should eq(%(#)) 243 | end 244 | 245 | it "works with small strings" do 246 | s = Fincher::IOScanner.new(::IO::Memory.new("hi")) 247 | s.inspect.should eq(%(#)) 248 | s.scan(/\w\w/) 249 | s.inspect.should eq(%(#)) 250 | end 251 | end 252 | 253 | describe Fincher::IOScanner, "#peek" do 254 | it "shows the next len characters without advancing the offset" do 255 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 256 | s.offset.should eq(0) 257 | s.peek(4).should eq("this") 258 | s.offset.should eq(0) 259 | s.peek(7).should eq("this is") 260 | s.offset.should eq(0) 261 | end 262 | end 263 | 264 | describe Fincher::IOScanner, "#reset" do 265 | it "resets the scan offset to the beginning and clears the last match" do 266 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 267 | s.scan_until(/str/) 268 | s[0]?.should_not be_nil 269 | s.offset.should_not eq(0) 270 | 271 | s.reset 272 | s[0]?.should be_nil 273 | s.offset.should eq(0) 274 | end 275 | end 276 | 277 | describe Fincher::IOScanner, "#terminate" do 278 | it "moves the scan offset to the end of the string and clears the last match" do 279 | s = Fincher::IOScanner.new(::IO::Memory.new("this is a string")) 280 | s.scan_until(/str/) 281 | s[0]?.should_not be_nil 282 | s.eos?.should eq(false) 283 | 284 | s.terminate 285 | s[0]?.should be_nil 286 | s.eos?.should eq(true) 287 | end 288 | end 289 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/fincher" 3 | -------------------------------------------------------------------------------- /spec/strategies/displacement/m_word_offset_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Fincher::DisplacementStrategies::MWordOffset do 4 | describe "#advance_to_next!" do 5 | scanner = IO::Memory.new("hello") 6 | 7 | describe "when the displacement is feasible" do 8 | m_word_offsetter = Fincher::DisplacementStrategies::MWordOffset.new( 9 | scanner, 10 | 123, 11 | 3 12 | ) 13 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem ipsum test blerg smorgasboorg")) 14 | output_io = IO::Memory.new 15 | 16 | it "adds the configured offset to the StringScanner#offset" do 17 | m_word_offsetter.advance_to_next!(source_text_scanner, Char::ZERO, io: output_io) 18 | source_text_scanner.offset.should eq(17) 19 | output_io.to_s.should eq("lorem ipsum test ") 20 | end 21 | end 22 | 23 | describe "when the displacement is not feasible" do 24 | m_word_offsetter = Fincher::DisplacementStrategies::MWordOffset.new( 25 | scanner, 26 | 123, 27 | 10 28 | ) 29 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem")) 30 | output_io = IO::Memory.new 31 | 32 | it "raises an exception" do 33 | expect_raises(Fincher::StrategyNotFeasibleError) do 34 | m_word_offsetter.advance_to_next!(source_text_scanner, Char::ZERO, io: output_io) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/strategies/displacement/matching_char_offset_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Fincher::DisplacementStrategies::MatchingCharOffset do 4 | describe "#advance_to_next!" do 5 | scanner = IO::Memory.new("hello") 6 | 7 | describe "when the displacement is feasible" do 8 | matching_char_offsetter = Fincher::DisplacementStrategies::MatchingCharOffset.new( 9 | scanner, 10 | 123, 11 | 3 12 | ) 13 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem ipsum test blerg smorgasboorg")) 14 | output_io = IO::Memory.new 15 | 16 | it "adds the configured offset to the StringScanner#offset" do 17 | matching_char_offsetter.advance_to_next!(source_text_scanner, 'r', io: output_io) 18 | source_text_scanner.offset.should eq(20) 19 | output_io.to_s.should eq("lorem ipsum test ble") 20 | end 21 | end 22 | 23 | describe "when the displacement is not feasible" do 24 | matching_char_offsetter = Fincher::DisplacementStrategies::MatchingCharOffset.new( 25 | scanner, 26 | 123, 27 | 10 28 | ) 29 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem")) 30 | output_io = IO::Memory.new 31 | 32 | it "raises an exception" do 33 | expect_raises(Fincher::StrategyNotFeasibleError) do 34 | matching_char_offsetter.advance_to_next!(source_text_scanner, 'r', io: output_io) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/strategies/displacement/n_char_offset_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Fincher::DisplacementStrategies::NCharOffset do 4 | describe "#advance_to_next!" do 5 | scanner = IO::Memory.new("hello") 6 | 7 | describe "when the displacement is feasible" do 8 | n_char_offsetter = Fincher::DisplacementStrategies::NCharOffset.new( 9 | scanner, 10 | 123.to_u32, 11 | 10 12 | ) 13 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem ipsum test")) 14 | output_io = IO::Memory.new 15 | 16 | it "adds the configured offset to the StringScanner#offset" do 17 | n_char_offsetter.advance_to_next!(source_text_scanner, Char::ZERO, io: output_io) 18 | source_text_scanner.pos = 10 19 | output_io.to_s.should eq("lorem ipsu") 20 | end 21 | end 22 | 23 | describe "when the displacement is not feasible" do 24 | n_char_offsetter = Fincher::DisplacementStrategies::NCharOffset.new( 25 | scanner, 26 | 123.to_u32, 27 | 10 28 | ) 29 | source_text_scanner = Fincher::IOScanner.new(IO::Memory.new("lorem")) 30 | output_io = IO::Memory.new 31 | 32 | it "raises an exception" do 33 | expect_raises(Fincher::StrategyNotFeasibleError) do 34 | n_char_offsetter.advance_to_next!(source_text_scanner, Char::ZERO, io: output_io) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/strategies/replacement/keymap_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Fincher::ReplacementStrategies::Keymap do 4 | describe "#replace!" do 5 | describe "when given a char" do 6 | it "replaces char with a neighboring char based on keymap" do 7 | keymap = Fincher::Types::Keymap.load!("en-US_qwerty") 8 | keymap_replacer = Fincher::ReplacementStrategies::Keymap.new(8.to_u32, keymap) 9 | to_replace = 'b' 10 | replaced = keymap_replacer.replace(to_replace) 11 | 12 | replaced.to_s.should match(/^[vghnVGHN]$/) 13 | end 14 | end 15 | 16 | describe "when given a string" do 17 | it "replaces each char in the string with a neighboring char based on keymap" do 18 | keymap = Fincher::Types::Keymap.load!("en-US_qwerty") 19 | keymap_replacer = Fincher::ReplacementStrategies::Keymap.new(8.to_u32, keymap) 20 | to_replace = "bbbbb" 21 | replaced = keymap_replacer.replace(to_replace) 22 | 23 | replaced.should match(/^[vghnVGHN]{5}$/) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/strategies/replacement/n_shifter_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Fincher::ReplacementStrategies::NShifter do 4 | describe "#replace!" do 5 | describe "when given a char" do 6 | it "replaces char with the codepoint + N codepoints" do 7 | n = 4 8 | shifter = Fincher::ReplacementStrategies::NShifter.new(8.to_u32, n) 9 | to_replace = 'b' 10 | replaced = shifter.replace(to_replace) 11 | 12 | replaced.should eq('b' + n) 13 | end 14 | end 15 | 16 | describe "when given a string" do 17 | it "replaces each char in the string with the codepoint + N codepoints" do 18 | n = 4 19 | shifter = Fincher::ReplacementStrategies::NShifter.new(8.to_u32, n) 20 | to_replace = "bbbbb" 21 | replaced = shifter.replace(to_replace) 22 | 23 | replaced.should eq("fffff") 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/transformer_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Fincher::Transformer do 4 | describe "#transform" do 5 | it "does stuff" do 6 | source = "I am a test sentence. The quick brown fox jumps over the lazy dog. That dog is lazy as fuck. God damn. How many dogs there gotta be that be like this man come on son. Why." 7 | source_scanner = IO::Memory.new(source) 8 | 9 | plaintext = "hello" 10 | plaintext_scanner = IO::Memory.new(plaintext) 11 | seed = 123.to_u32 12 | 13 | transformer = Fincher::Transformer.new( 14 | plaintext_scanner, 15 | source_scanner, 16 | Fincher::DisplacementStrategies::NCharOffset.new(plaintext_scanner, seed, 20), 17 | Fincher::ReplacementStrategies::NShifter.new(seed, 0) 18 | ) 19 | 20 | transformed = IO::Memory.new(source.bytesize) 21 | transformer.transform(transformed) 22 | 23 | transformed.to_s.should eq( 24 | "I am a test sentenceh The quick brown foxejumps over the lazy log. That dog is lazylas fuck. God damn. How many dogs there gotta be that be like this man come on son. Why." 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/cli.cr: -------------------------------------------------------------------------------- 1 | require "./fincher" 2 | 3 | Colorize.on_tty_only! 4 | Fincher.debug = true 5 | Fincher::CLI.run ARGV 6 | -------------------------------------------------------------------------------- /src/fincher.cr: -------------------------------------------------------------------------------- 1 | require "baked_file_system" 2 | require "colorize" 3 | require "cli" 4 | require "random/secure" 5 | require "yaml" 6 | require "./fincher/*" 7 | 8 | module Fincher 9 | @@debug = false 10 | 11 | def self.debug=(value) 12 | @@debug = value 13 | end 14 | 15 | def self.debug(msg) 16 | if debug = @@debug 17 | STDERR.puts "[+] #{msg}".colorize(:light_gray) 18 | end 19 | end 20 | 21 | def self.info(msg) 22 | STDERR.puts msg 23 | end 24 | 25 | def self.error(msg) 26 | STDERR.puts "#{msg}".colorize(:red) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/fincher/cli.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | class CLI < ::Cli::Supercommand 3 | version Fincher::VERSION 4 | command_name "fincher" 5 | 6 | class Options 7 | help 8 | end 9 | 10 | class Help 11 | header "Encodes a message as typos within a source text." 12 | footer "Copyright (c) 2017 Max Fierke. Licensed under The MIT License." 13 | end 14 | 15 | class Encode < ::Cli::Command 16 | @seed : UInt32? 17 | 18 | class Options 19 | arg "message", required: true, desc: "message" 20 | arg "source_text_file", required: false, desc: "source text file. STDIN, if omitted" 21 | string "--seed", 22 | var: "NUMBER", 23 | required: false, 24 | default: "", 25 | desc: "seed value. randomly generated if omitted" 26 | string "--displacement-strategy", 27 | var: "STRING", 28 | default: "matching-char-offset", 29 | desc: "displacement strategy (Options: char-offset, word-offset, matching-char-offset)" 30 | string "--replacement-strategy", 31 | var: "STRING", 32 | default: "keymap", 33 | desc: "replacement strategy (Options: n-shifter, keymap)" 34 | string "--char-offset", 35 | var: "NUMBER", 36 | default: "130", 37 | desc: "character gap between typos (Displacement Strategies: char-offset)" 38 | string "--word-offset", 39 | var: "NUMBER", 40 | default: "38", 41 | desc: "word gap between typos (Displacement Strategies: word-offset, matching-char-offset)" 42 | string "--codepoint-shift", 43 | var: "NUMBER", 44 | default: "7", 45 | desc: "codepoints to shift (Replacement Strategies: n-shifter)" 46 | string "--keymap", 47 | var: "STRING", 48 | default: "en-US_qwerty", 49 | desc: "Keymap definition to use for keymap replacement strategy" 50 | end 51 | 52 | class Help 53 | caption "encode message" 54 | end 55 | 56 | def run(io = STDOUT) 57 | plaintext_scanner = ::IO::Memory.new(args.message) 58 | displacement_strategy = options.displacement_strategy 59 | replacement_strategy = options.replacement_strategy 60 | 61 | source_file = if source_text_file = args.source_text_file? 62 | File.open(source_text_file) 63 | else 64 | STDIN 65 | end 66 | 67 | transformer = Fincher::Transformer.new( 68 | plaintext_scanner, 69 | source_file, 70 | displacement_strategy_for(displacement_strategy, plaintext_scanner, options), 71 | replacement_strategy_for(replacement_strategy, options) 72 | ).transform(io) 73 | rescue e : ::Fincher::Error 74 | Fincher.error e.message 75 | ensure 76 | source_file.close if source_file 77 | end 78 | 79 | private def displacement_strategy_for(strategy, plaintext_scanner, options) 80 | case strategy 81 | when "word-offset" 82 | word_offset = options.word_offset.to_i 83 | Fincher::DisplacementStrategies::MWordOffset.new(plaintext_scanner, seed, word_offset) 84 | when "char-offset" 85 | char_offset = options.char_offset.to_i 86 | Fincher::DisplacementStrategies::NCharOffset.new(plaintext_scanner, seed, char_offset) 87 | when "matching-char-offset" 88 | word_offset = options.word_offset.to_i 89 | Fincher::DisplacementStrategies::MatchingCharOffset.new(plaintext_scanner, seed, word_offset) 90 | else 91 | raise StrategyDoesNotExistError.new( 92 | "Displacement strategy '#{strategy}' does not exist." 93 | ) 94 | end 95 | end 96 | 97 | private def replacement_strategy_for(strategy, options) 98 | case strategy 99 | when "n-shifter" 100 | codepoint_shift = options.codepoint_shift.to_i 101 | Fincher::ReplacementStrategies::NShifter.new(seed, codepoint_shift) 102 | when "keymap" 103 | keymap_name = options.keymap 104 | keymap = Fincher::Types::Keymap.load!(keymap_name) 105 | Fincher::ReplacementStrategies::Keymap.new(seed, keymap) 106 | else 107 | raise StrategyDoesNotExistError.new( 108 | "Replacement strategy '#{strategy}' does not exist." 109 | ) 110 | end 111 | end 112 | 113 | private def seed 114 | @seed ||= options.seed.empty? ? generate_seed : options.seed.to_u32 115 | end 116 | 117 | private def generate_seed 118 | s = Random::Secure.hex(4).to_u32(16) 119 | Fincher.info "Using #{s} as seed" 120 | s 121 | end 122 | end 123 | 124 | class Version < ::Cli::Command 125 | class Help 126 | caption "print the version" 127 | end 128 | 129 | def run 130 | puts "fincher #{version}" 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /src/fincher/embedded_fs.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | class EmbeddedFs 3 | extend BakedFileSystem 4 | 5 | bake_folder "../../data" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/fincher/errors.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | class Error < Exception 3 | end 4 | 5 | class StrategyNotFeasibleError < ::Fincher::Error 6 | end 7 | 8 | class StrategyDoesNotExistError < ::Fincher::Error 9 | end 10 | 11 | class UnknownKeyError < ::Fincher::Error 12 | end 13 | 14 | class UnknownKeymapError < ::Fincher::Error 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /src/fincher/io_scanner.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | class IOScanner 3 | BUFFER_SIZE = 4096 4 | 5 | @last_match : ::Regex::MatchData? 6 | @buffer = "" 7 | @buffer_cursor = 0 8 | @buffer_io_offset : Int32 | Int64 = 0 9 | @eof_reached = false 10 | 11 | getter io 12 | getter last_match 13 | forward_missing_to io 14 | 15 | def initialize(@io : Fincher::IO) 16 | end 17 | 18 | def stdin? 19 | io == STDIN 20 | end 21 | 22 | def [](index) 23 | @last_match.not_nil![index] 24 | end 25 | 26 | def []?(index) 27 | @last_match.try(&.[index]?) 28 | end 29 | 30 | def eos? 31 | offset >= size 32 | end 33 | 34 | def peek(len) 35 | buffer[offset, len] 36 | end 37 | 38 | def rest 39 | rest_of_buffer + io.gets_to_end 40 | end 41 | 42 | def gets_to_end 43 | rest 44 | end 45 | 46 | def string 47 | @buffer + io.gets_to_end 48 | end 49 | 50 | def reset 51 | reset_buffer_match! 52 | io.rewind 53 | end 54 | 55 | def terminate 56 | @last_match = nil 57 | @buffer_cursor = buffer_size 58 | io.close 59 | end 60 | 61 | def check(pattern) 62 | match(pattern, advance: false, options: Regex::MatchOptions::ANCHORED) 63 | end 64 | 65 | def check_until(pattern) 66 | match(pattern, advance: false, options: Regex::MatchOptions::None) 67 | end 68 | 69 | def skip(bytes_count : Int) 70 | self.offset += bytes_count 71 | end 72 | 73 | def skip(pattern : Regex) 74 | match = scan(pattern) 75 | match.size if match 76 | end 77 | 78 | def skip_until(pattern) 79 | match = scan_until(pattern) 80 | match.size if match 81 | end 82 | 83 | def scan(pattern) 84 | match(pattern, advance: true, options: Regex::MatchOptions::ANCHORED) 85 | end 86 | 87 | def scan_until(pattern) 88 | match(pattern, advance: true, options: Regex::MatchOptions::None) 89 | end 90 | 91 | def offset 92 | @buffer_io_offset + @buffer_cursor 93 | end 94 | 95 | def offset=(position) 96 | raise IndexError.new if position < 0 97 | 98 | buffer_io_offset = @buffer_io_offset 99 | buffer_cursor = @buffer_cursor 100 | buffer = @buffer 101 | buffer_io_end_offset = buffer_io_offset + buffer.bytesize 102 | 103 | advance = (position - (buffer_io_offset + buffer_cursor)).to_i32 104 | 105 | if position > buffer_io_offset && position < buffer_io_end_offset 106 | @buffer_cursor += advance 107 | elsif stdin? 108 | # We cannot go backwards with STDIN 109 | raise IndexError.new if position < buffer_io_offset 110 | 111 | @buffer_io_offset = position 112 | 113 | begin 114 | io.skip(advance) 115 | rescue ::IO::EOFError 116 | # next_buffer! will handle EOF 117 | end 118 | 119 | next_buffer!(position) 120 | else 121 | # We don't want to overrun 122 | raise IndexError.new if position >= size 123 | @buffer_io_offset = position 124 | io.pos = position 125 | @eof_reached = false 126 | next_buffer!(position) 127 | end 128 | end 129 | 130 | def pos 131 | offset 132 | end 133 | 134 | def pos=(position) 135 | self.offset = position 136 | end 137 | 138 | def print_buffer_position 139 | puts @buffer 140 | puts "#{" " * (Math.max(0, @buffer_cursor - 1))}^" 141 | end 142 | 143 | def size 144 | case _io = io 145 | when ::IO::FileDescriptor 146 | _io.info.size 147 | else 148 | if _io.responds_to?(:size) 149 | _io.size 150 | else 151 | raise "Unsupported IO subclass" 152 | end 153 | end 154 | end 155 | 156 | def inspect(stream : ::IO) 157 | stream << "#" 162 | end 163 | 164 | private def buffer 165 | if @buffer.empty? 166 | next_buffer! 167 | else 168 | @buffer 169 | end 170 | end 171 | 172 | private def rest_of_buffer 173 | buffer[@buffer_cursor, buffer_size] 174 | end 175 | 176 | private def buffer_size 177 | @buffer.bytesize 178 | end 179 | 180 | private def match(pattern, **kwargs) 181 | last_match_str = nil 182 | 183 | if buffer = @buffer 184 | last_match_str = buffer_match(pattern, **kwargs) 185 | end 186 | 187 | unless last_match_str 188 | each_buffer do |buffer| 189 | last_match_str = buffer_match(pattern, **kwargs) 190 | break if last_match_str 191 | end 192 | end 193 | 194 | last_match_str 195 | end 196 | 197 | private def buffer_match(pattern, advance = true, options = Regex::MatchOptions::ANCHORED) 198 | match = pattern.match_at_byte_index(@buffer, @buffer_cursor, options) 199 | if match 200 | start = @buffer_cursor 201 | new_byte_offset = match.byte_end(0).to_i 202 | @buffer_cursor = new_byte_offset if advance 203 | 204 | @last_match = match 205 | @buffer.byte_slice(start, new_byte_offset - start) 206 | else 207 | @last_match = nil 208 | end 209 | end 210 | 211 | private def each_buffer 212 | while !io_eof? 213 | buf = next_buffer! 214 | yield buf 215 | end 216 | end 217 | 218 | private def reset_buffer_match! 219 | @last_match = nil 220 | @eof_reached = false 221 | @buffer_io_offset = 0 222 | @buffer_cursor = 0 223 | @buffer = "" 224 | end 225 | 226 | private def next_buffer!(anchor_position = nil) 227 | before_offset = anchor_position || offset 228 | 229 | buf = Bytes.new(BUFFER_SIZE) 230 | bytes_read = io.read(buf) 231 | 232 | if bytes_read < BUFFER_SIZE 233 | @eof_reached = true 234 | end 235 | 236 | if bytes_read > 0 237 | buf = String.new(buf[0, bytes_read]) 238 | else 239 | buf = String.new 240 | end 241 | 242 | @buffer_io_offset = if stdin? 243 | before_offset + bytes_read 244 | else 245 | io.pos - bytes_read 246 | end 247 | @buffer_cursor = 0 248 | @buffer = buf 249 | buf 250 | end 251 | 252 | private def io_eof? 253 | @eof_reached || io.closed? 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /src/fincher/strategies/displacement/base.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module DisplacementStrategies 3 | abstract class Base 4 | getter seed 5 | getter plaintext_scanner 6 | 7 | def initialize(@plaintext_scanner : Fincher::IO, @seed : UInt32) 8 | end 9 | 10 | abstract def advance_to_next!(scanner : Fincher::IOScanner, msg_char : Char, io : ::IO) : Fincher::IOScanner 11 | 12 | abstract def is_feasible?(scanner : Fincher::IOScanner) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/fincher/strategies/displacement/m_word_offset.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module DisplacementStrategies 3 | class MWordOffset < Base 4 | getter offset 5 | 6 | def initialize(@plaintext_scanner : Fincher::IO, @seed : UInt32, @offset : Int32) 7 | end 8 | 9 | def advance_to_next!(scanner : Fincher::IOScanner, msg_char : Char, io : ::IO) : Fincher::IOScanner 10 | offset.times do 11 | io << scanner.scan_until(/\b[\w\-\']+\b\W+\b/im) 12 | 13 | raise StrategyNotFeasibleError.new( 14 | "Cannot advance #{offset} words at scanner position #{scanner.pos}" 15 | ) unless is_feasible?(scanner) 16 | end 17 | scanner 18 | end 19 | 20 | def is_feasible?(scanner : Fincher::IOScanner) 21 | !scanner.last_match.nil? 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /src/fincher/strategies/displacement/matching_char_offset.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module DisplacementStrategies 3 | class MatchingCharOffset < Base 4 | getter offset 5 | 6 | def initialize(@plaintext_scanner : Fincher::IO, @seed : UInt32, @offset : Int32) 7 | end 8 | 9 | def advance_to_next!(scanner : Fincher::IOScanner, msg_char : Char, io : IO) : Fincher::IOScanner 10 | offset.times do 11 | io << scanner.scan_until(/\b[\w\-\']+\b\W+\b/im) 12 | 13 | raise StrategyNotFeasibleError.new( 14 | "Cannot advance #{offset} words for character '#{msg_char}' at scanner position #{scanner.pos}" 15 | ) unless is_feasible?(scanner) 16 | end 17 | 18 | matching_char_str = scanner.scan_until(/#{msg_char}/i) 19 | raise StrategyNotFeasibleError.new( 20 | "Cound not find character '#{msg_char}' after #{offset} words at scanner position #{scanner.pos}" 21 | ) unless matching_char_str 22 | io << matching_char_str[0...-1] 23 | scanner.offset = scanner.offset - 1 24 | scanner 25 | end 26 | 27 | def is_feasible?(scanner : Fincher::IOScanner) 28 | !scanner.last_match.nil? 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/fincher/strategies/displacement/n_char_offset.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module DisplacementStrategies 3 | class NCharOffset < Base 4 | getter offset 5 | 6 | def initialize(@plaintext_scanner : Fincher::IO, @seed : UInt32, @offset : Int32) 7 | end 8 | 9 | def advance_to_next!(scanner : Fincher::IOScanner, msg_char : Char, io : IO) : Fincher::IOScanner 10 | raise StrategyNotFeasibleError.new( 11 | "Cannot advance #{offset} chars at scanner position #{scanner.pos}" 12 | ) unless is_feasible?(scanner) 13 | 14 | io << scanner.scan(/.{#{offset}}/) 15 | 16 | scanner 17 | end 18 | 19 | def is_feasible?(scanner : Fincher::IOScanner) 20 | scanner.stdin? || (scanner.size > scanner.pos + offset) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/fincher/strategies/replacement/base.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module ReplacementStrategies 3 | abstract class Base 4 | getter seed 5 | 6 | def initialize(@seed : UInt32) 7 | end 8 | 9 | abstract def replace(to_replace : Char) : Char 10 | abstract def replace(to_replace : String) : String 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/fincher/strategies/replacement/keymap.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module ReplacementStrategies 3 | class Keymap < Base 4 | getter keymap 5 | 6 | def initialize(@seed : UInt32, @keymap : Fincher::Types::Keymap) 7 | end 8 | 9 | def replace(to_replace : Char) : Char 10 | keymap_replace(to_replace) 11 | end 12 | 13 | def replace(to_replace : String) : String 14 | to_replace.gsub { |c| keymap_replace(c) } 15 | end 16 | 17 | private def keymap_replace(to_replace : Char) : Char 18 | keymap_entry = keymap[to_replace.to_s]? 19 | 20 | if keymap_entry 21 | keymap_entry.neighbors.sample(sampler).char_at(0) 22 | else 23 | raise UnknownKeyError.new("Unknown key '#{to_replace}' in keymap") 24 | end 25 | end 26 | 27 | private def sampler 28 | @sampler ||= Random.new(seed) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/fincher/strategies/replacement/n_shifter.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module ReplacementStrategies 3 | class NShifter < Base 4 | getter n 5 | 6 | def initialize(@seed : UInt32, @n : Int32) 7 | end 8 | 9 | def replace(to_replace : Char) : Char 10 | n_shift(to_replace) 11 | end 12 | 13 | def replace(to_replace : String) : String 14 | to_replace.gsub { |c| n_shift(c) } 15 | end 16 | 17 | private def n_shift(char : Char) : Char 18 | if char < Char::MAX 19 | char + n 20 | else 21 | '\u{1}' 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/fincher/strategies/strategies.cr: -------------------------------------------------------------------------------- 1 | require "./displacement/*" 2 | require "./replacement/*" 3 | 4 | module Fincher 5 | module DisplacementStrategies 6 | end 7 | 8 | module ReplacementStrategies 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/fincher/transformer.cr: -------------------------------------------------------------------------------- 1 | require "./types/*" 2 | require "./strategies/*" 3 | 4 | module Fincher 5 | class Transformer 6 | getter plaintext_scanner 7 | getter source_scanner 8 | 9 | def initialize( 10 | @plaintext_scanner : Fincher::IO, 11 | @source_stream : Fincher::IO, 12 | @displacement_strategy : Fincher::DisplacementStrategies::Base, 13 | @replacement_strategy : Fincher::ReplacementStrategies::Base 14 | ) 15 | end 16 | 17 | def transform(io) : ::IO 18 | current_offset = 0 19 | 20 | source_scanner = IOScanner.new(@source_stream) 21 | 22 | plaintext_scanner.each_char do |msg_char| 23 | # Advance position 24 | displacer.advance_to_next!(source_scanner, msg_char, io: io) 25 | 26 | # Replace the next char 27 | replaced_char = replacer.replace(msg_char) 28 | io << replaced_char 29 | 30 | # Skip the current char, since we just replace it 31 | source_scanner.skip(1) 32 | end 33 | 34 | io << source_scanner.gets_to_end 35 | io 36 | end 37 | 38 | def displacer 39 | @displacement_strategy 40 | end 41 | 42 | def replacer 43 | @replacement_strategy 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /src/fincher/types.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | alias IO = ::IO::FileDescriptor | ::IO::Memory 3 | end 4 | -------------------------------------------------------------------------------- /src/fincher/types/keymap.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module Types 3 | class Keymap 4 | include YAML::Serializable 5 | 6 | property data : Hash(String, KeymapEntry) 7 | 8 | def [](key) 9 | data[key]?.not_nil! 10 | end 11 | 12 | def []?(key) 13 | data.fetch(key.to_s) do |key| 14 | result = data.find { |k, v| v.shift == key } 15 | if result 16 | result[1] 17 | else 18 | nil 19 | end 20 | end 21 | end 22 | 23 | def self.load!(keymap_name) 24 | keymap_file = Fincher::EmbeddedFs.get?("keymaps/#{keymap_name}.yml") 25 | 26 | if keymap_file 27 | keymap_yml = keymap_file.gets_to_end 28 | from_yaml(keymap_yml) 29 | else 30 | raise UnknownKeymapError.new( 31 | "Keymap '#{keymap_name}' does not exist. Are you able to define it?\ 32 | Please open a PR on https://github.com/maxfierke/fincher" 33 | ) 34 | end 35 | end 36 | 37 | def self.from_yaml(*args) 38 | super(*args).not_nil!.dereference! 39 | end 40 | 41 | def dereference! 42 | data.map do |key, value| 43 | value.neighbors = value.neighbors.flat_map do |neighbor| 44 | if data[neighbor]? 45 | neighbor_entry = data[neighbor] 46 | [neighbor, neighbor_entry.shift] 47 | else 48 | raise UnknownKeyError.new("Unknown key '#{neighbor}' in keymap") 49 | end 50 | end 51 | end 52 | 53 | self 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /src/fincher/types/keymap_entry.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | module Types 3 | class KeymapEntry 4 | include YAML::Serializable 5 | 6 | property shift : String 7 | property neighbors : Array(String) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/fincher/version.cr: -------------------------------------------------------------------------------- 1 | module Fincher 2 | VERSION = "0.1.1" 3 | end 4 | --------------------------------------------------------------------------------