├── .github
└── workflows
│ └── swift.yml
├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ ├── Pouch-Package.xcscheme
│ ├── PouchFramework.xcscheme
│ └── pouch.xcscheme
├── Changelog.md
├── Code of Conduct.md
├── License.md
├── Makefile
├── Package.resolved
├── Package.swift
├── Readme.md
├── Sources
├── Pouch
│ ├── Commands
│ │ ├── Pouch.swift
│ │ └── Retrieve.swift
│ └── main.swift
└── PouchFramework
│ ├── Chalk.swift
│ ├── Defaults.swift
│ ├── Engine.swift
│ ├── Extensions
│ ├── Collection.swift
│ ├── Sequence.swift
│ ├── String.swift
│ ├── UInt8.swift
│ └── URL.swift
│ ├── Generators
│ └── Swift
│ │ ├── SwiftCipherContentsGenerating.swift
│ │ ├── SwiftGenerator.swift
│ │ └── SwiftXorGenerator.swift
│ ├── Logger.swift
│ ├── Models
│ ├── Cipher.swift
│ ├── Configuration.swift
│ ├── DecryptionFile.swift
│ ├── Input.swift
│ ├── LanguageOutput.swift
│ ├── Languages
│ │ └── SwiftConfig.swift
│ ├── Output.swift
│ ├── Secret.swift
│ └── SecretDeclaration.swift
│ └── VariableFetchers
│ ├── EnvironmentVariablesFetcher.swift
│ ├── VariableFetching.swift
│ └── VariableFetchingError.swift
└── Tests
├── LinuxMain.swift
└── PouchTests
├── Generators
└── SwiftGeneratorTests.swift
├── Parsers
├── ConfigurationParsingTests.swift
├── OutputParsingTests.swift
└── SecretParsingTests.swift
├── PouchTests.swift
├── Utilities.swift
└── XCTestManifests.swift
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | name: Swift
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: macos-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v2
16 | - name: Build
17 | run: swift build -v
18 | - name: Run tests
19 | run: swift test -v
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | Secrets.swift
7 | .pouch.yml
8 | pouch.zip
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Pouch-Package.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
43 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
61 |
67 |
68 |
69 |
70 |
71 |
81 |
82 |
88 |
89 |
90 |
91 |
97 |
98 |
104 |
105 |
106 |
107 |
109 |
110 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/PouchFramework.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 |
62 |
63 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/pouch.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/Changelog.md:
--------------------------------------------------------------------------------
1 | ## Next
2 |
3 | ## 0.2.0 (2021-04-02)
4 | - Added `swiftlint:disable all` to the top of the file. [@Igor-Palaguta]
5 | - Added "Generated using Pouch" to the top of the file. [@Igor-Palaguta]
6 | - Fixed spacing in the generated file. [@Igor-Palaguta]
7 |
8 | ## 0.1.2 (2021-03-24)
9 | - Resigned from building fat binaries due to problems with brew & intel macs.
10 |
11 | ## 0.1.1 (2021-03-24)
12 | - Small updates to Makefile for brew support.
13 |
14 | ## 0.1.0 (2021-03-24)
15 | - No need for `filePath` keyword in the `.pouch.yml` output if there is a single path (with multiple config options you need to type parameter names, similar to how secret parsing works).
16 | - `pouch` will now by default run `pouch retrieve`.
17 | - Fixed a bug where if you didn't provide output language it would print parser error.
18 |
19 | ## 0.0.1 (2021-03-15)
20 | - Initial release 🥳
21 |
22 | [@Igor-Palaguta]: https://github.com/Igor-Palaguta
--------------------------------------------------------------------------------
/Code of Conduct.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | thesunshinejr@gmail.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/License.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020-present @sunshinejr
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 all
13 | 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 THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Slightly adjusted from: https://github.com/yonaskolb/Mint/blob/master/Makefile (thanks @yonaskolb!)
2 | EXECUTABLE_NAME = pouch
3 | REPO = https://github.com/sunshinejr/Pouch
4 | VERSION = 0.2.0
5 |
6 | PREFIX = /usr/local
7 | INSTALL_PATH = $(PREFIX)/bin/$(EXECUTABLE_NAME)
8 | BUILD_PATH = .build/release/$(EXECUTABLE_NAME)
9 | CURRENT_PATH = $(PWD)
10 | RELEASE_TAR = $(REPO)/archive/$(VERSION).tar.gz
11 | CURRENT_DATE = $(shell date +%F)
12 |
13 | .PHONY: install build uninstall publish release
14 |
15 | install: build
16 | mkdir -p $(PREFIX)/bin
17 | cp -f $(BUILD_PATH) $(INSTALL_PATH)
18 |
19 | build:
20 | swift build --disable-sandbox -c release
21 |
22 | uninstall:
23 | rm -f $(INSTALL_PATH)
24 |
25 | publish: zip_binary bump_brew
26 | echo "Published $(VERSION)"
27 |
28 | bump_brew:
29 | brew update
30 | brew bump-formula-pr --url=$(RELEASE_TAR) Pouch
31 |
32 | zip_binary: build
33 | zip -jr $(EXECUTABLE_NAME).zip $(BUILD_PATH)
34 |
35 | release:
36 | sed -i '' 's|\(version: "\)\(.*\)\("\)|\1$(VERSION)\3|' Sources/Pouch/Commands/Pouch.swift
37 | sed -i '' 's/## Next/## Next\n\n## $(VERSION) ($(CURRENT_DATE))/g' Changelog.md
38 |
39 | git add .
40 | git commit -m "Releasing $(VERSION)"
41 | git tag $(VERSION)
42 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "object": {
3 | "pins": [
4 | {
5 | "package": "swift-argument-parser",
6 | "repositoryURL": "https://github.com/apple/swift-argument-parser",
7 | "state": {
8 | "branch": null,
9 | "revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc",
10 | "version": "0.3.2"
11 | }
12 | },
13 | {
14 | "package": "Yams",
15 | "repositoryURL": "https://github.com/jpsim/Yams",
16 | "state": {
17 | "branch": null,
18 | "revision": "9003d51672e516cc59297b7e96bff1dfdedcb4ea",
19 | "version": "4.0.4"
20 | }
21 | }
22 | ]
23 | },
24 | "version": 1
25 | }
26 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "Pouch",
6 | platforms: [.macOS(.v10_11)],
7 | products: [
8 | .executable(name: "pouch", targets: ["Pouch"]),
9 | .library(name: "PouchFramework", targets: ["PouchFramework"])
10 | ],
11 | dependencies: [
12 | .package(name: "swift-argument-parser", url: "https://github.com/apple/swift-argument-parser", .exact("0.3.2")),
13 | .package(name: "Yams", url: "https://github.com/jpsim/Yams", .exact("4.0.4")),
14 | ],
15 | targets: [
16 | .target(
17 | name: "Pouch",
18 | dependencies: [
19 | .target(name: "PouchFramework"),
20 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
21 | .product(name: "Yams", package: "Yams")
22 | ]),
23 | .target(
24 | name: "PouchFramework",
25 | dependencies: [
26 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
27 | ]),
28 | .testTarget(name: "PouchTests", dependencies: ["PouchFramework", "Yams"]),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # Pouch
2 | Secret management tool written in Swift. This was heavily inspired by [CcocoaPods-Keys](https://github.com/orta/cocoapods-keys) & [NSHipster article regarding secret management](https://nshipster.com/secrets/).
3 |
4 | ## Usage
5 |
6 | Set up a config file for a project once:
7 | ```yaml
8 | secrets:
9 | - API_KEY
10 | - API_SECRET
11 | outputs:
12 | - ./Secrets.swift
13 | ```
14 |
15 | Now, with `API_KEY` and `API_SECRET` stored in environment variables, you can generate a file with secrets using:
16 | ```
17 | pouch retrieve
18 | ```
19 |
20 | Which should generate an output similar to this one (applied `xor` on a string (+ randomly generated salt) with a reverse func to read it in the app)):
21 | ```swift
22 | import Foundation
23 |
24 | enum Secret {
25 | static let apiKey: String = Secret._xored([15, 26, 26, 243, 46, 124, 234, 140, 48, 169, 192], salt: [97, 115, 121, 150, 65, 18, 143, 225, 81, 221, 165, 134, 36, 222, 157, 20, 172, 203, 97, 8, 26, 81, 49, 144, 147, 1, 197, 21, 35, 32, 83, 156, 247, 108, 211, 108, 202, 174, 119, 134, 141, 176, 180, 38, 171, 110, 89, 21, 213, 32, 171, 146, 63, 245, 87, 139, 162, 194, 63, 57, 75, 0, 165, 122, 142])
26 | static let apiSecret: String = Secret._xored([153, 59, 35, 31, 242, 106, 45, 3, 19, 67, 207, 9, 190, 40, 55, 197, 218, 221, 1, 40, 170, 117, 103, 211, 204, 168, 44, 18, 39, 207, 44, 158, 217, 135, 163, 16, 145, 120, 158, 221, 212, 49, 229, 116, 188, 145, 91, 203, 174, 184, 158, 78, 146, 106, 100, 166, 93, 239, 8, 18, 38, 129, 97, 249, 218, 137, 48, 58, 80, 252, 102, 47, 7, 92, 90, 194, 64, 61, 151, 221, 39], salt: [252, 85, 73, 112, 139, 3, 67, 100, 51, 55, 167, 96, 205, 8, 68, 168, 187, 177, 109, 8, 222, 26, 8, 191, 243, 136, 101, 50, 80, 160, 89, 242, 189, 167, 207, 127, 231, 29, 190, 174, 187, 92, 128, 84, 212, 244, 55, 187, 142, 207, 247, 58, 250, 74, 13, 210, 124, 207, 88, 64, 85, 174, 8, 138, 169])
27 |
28 | private static func _xored(_ secret: [UInt8], salt: [UInt8]) -> String {
29 | return String(bytes: secret.enumerated().map { index, character in
30 | return character ^ salt[index % salt.count]
31 | }, encoding: .utf8) ?? ""
32 | }
33 | }
34 | ```
35 |
36 |
37 | Now, add this file to your project structure (and to `.gitignore`) and use it!
38 |
39 | ```swift
40 | api.auth(key: Secret.apiKey, secret: Secret.apiSecret)
41 | ```
42 |
43 | Note: The idea is that each developer would regenerate that file and not commit to the repository (however, you can use it however you want).
44 |
45 | ## Why?
46 | Let's face it - managing secret keys is not an easy task. We usually want:
47 | 1. Protect ourselves against unwanted intruders that gain access to the repository (plain text is bad and so is e.g. symetric cryptography that only needs the attacker to either run the code to get the keys or calculate the secret by hand)
48 | 2. Protect ourselves against unwanted access to binary through e.g. jailbreak (e.g. in iOS the binary will hold plain text keys even if you ignored them in git, storing them in `.xcconfig` makes it even easier to the attacker)
49 | 3. The secret management to be as simple as possible.
50 |
51 | While writing this tool there was nothing that I found that helped with all of the above.
52 |
53 | `pouch` will make sure that static analysis tools will not be able to get to the keys easily. Though, for your own good, do not commit this file to the repository.
54 |
55 |
56 | ## Configuration options
57 | The config is in [YAML](https://yaml.org/spec/1.2/spec.html) format. By default the tool will look for `.pouch.yml` file, but you can provide a custom file path as a parameter:
58 | ```
59 | pouch retrieve --config ./.custom.pouch.yml
60 | ```
61 |
62 | For the config itself, you are required to have at least one secret and one output:
63 | ```yaml
64 | secrets:
65 | - API_KEY
66 | outputs:
67 | - ./Secrets.swift
68 | ```
69 |
70 | Though, there are also custom properties you can set.
71 |
72 | ### Generated type name
73 | You can change the generated type name (it's `Secrets` by default):
74 | ```yaml
75 | secrets:
76 | - API_KEY
77 | outputs:
78 | - filePath: ./Constants.swift
79 | typeName: Constant
80 | ```
81 |
82 | ### Generated secret name
83 | You are also able to provide a custom generated name for a secret (otherwise it will do the `camelCase`):
84 | ```yaml
85 | secrets:
86 | - name: API_KEY
87 | generatedName: youtubeApiKey
88 | - API_SECRET
89 | outputs:
90 | - ./Secrets.swift
91 | ```
92 |
93 | There are also things like custom inputs, but for now we only support environment variables.
94 |
95 | ## Installing
96 | You can either build & install it by using my Homebrew tap:
97 | ```
98 | brew install sunshinejr/formulae/pouch
99 | ```
100 | or by cloning the repo and using Make:
101 | ```
102 | make install
103 | ```
104 |
105 | ## Contributing
106 | This project is at its early stage and it currently only supports `xor` with random salt & only Swift output, but I'm open to:
107 | - Adding new ciphers (ideally something that works on all characters, e.g. `Caesar` is great but shifting might be problematic for things like emoji)
108 | - Adding new cipher options (e.g. salt length)
109 | - Adding new outputs (e.g. Kotlin, though I'd love to add only things that would be quite useful, not just a PoC)
110 |
111 | ## Notes about security
112 | While this is for sure an improvement to your normal, plain-text based flow, this doesn't guarantee that your keys won't be reverse-engineered.
113 | If you want to learn more about secret management and it's security, I recomend you to read the whole article I linked at the top of the Readme: [NSHipster article regarding secret management](https://nshipster.com/secrets/)
114 |
115 | ## Kudos
116 | - to [@nshipster](https://github.com/NSHipster) folks for awesome articles, especially on [secret management](https://nshipster.com/secrets/) & [Homebrew releases](https://nshipster.com/homebrew/),
117 | - to [@orta](https://github.com/orta) for [cocoapods-keys](https://github.com/orta/cocoapods-keys),
118 | - to [@yonaskolb](https://github.com/yonaskolb) and the whole team behind Mint for a [pretty nice Makefile template in Mint]() that I slightly modified,
119 | - to [@mxcl](https://github.com/mxcl) for a Swift clone of [Chalk](https://github.com/mxcl/Chalk) that Pouch uses for logging,
120 | - to [@jpsim](https://github.com/jpsim) and the contributors of [Yams](https://github.com/jpsim/Yams) for a yummy YAML parser that just works,
121 | - to [@natecook1000](https://github.com/natecook1000) and the contributors of [Swift Argument Parser](https://github.com/apple/swift-argument-parser) for an awesome experience that is building a modern command line tool
122 |
123 | ## License
124 | [Mit](License.md)
125 |
--------------------------------------------------------------------------------
/Sources/Pouch/Commands/Pouch.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | public struct Pouch: ParsableCommand {
5 | public static var configuration = CommandConfiguration(
6 | abstract: "A utility tool for secret management",
7 | version: "0.2.0",
8 | subcommands: [Retrieve.self],
9 | defaultSubcommand: Retrieve.self
10 | )
11 |
12 | public init() {}
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Pouch/Commands/Retrieve.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import PouchFramework
4 | import Yams
5 |
6 | public struct Retrieve: ParsableCommand {
7 | public static let configuration = CommandConfiguration(abstract: "Retrieve secrets & generate files at given paths with given configuration.")
8 |
9 | @Option(help: "The config file used to generate the files.")
10 | var config: String = "./.pouch.yml"
11 |
12 | public init() {}
13 |
14 | public func run() throws {
15 | do {
16 | // Since we cannot extend YAMLDecoder to attach a logger, work around that by setting a global logger property.
17 | PouchFramework.logger = logger
18 | logger.log(.parser, "Reading \(config, color: .green)...")
19 | let config = try String(contentsOf: URL(fileURLWithPath: self.config))
20 | let mappedConfig: Configuration = try YAMLDecoder().decode(from: config)
21 | logger.log(.parser, "\(self.config, color: .green) parsed successfully!")
22 | Engine().createFiles(configuration: mappedConfig)
23 | } catch {
24 | logger.log(.parser, "Error when parsing \(config, color: .blue): \(error, color: .red)")
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/Pouch/main.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import PouchFramework
4 |
5 | let logger = Logger(output: .print)
6 |
7 | Pouch.main()
8 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Chalk.swift:
--------------------------------------------------------------------------------
1 | /// Courtesy of @mxcl: https://github.com/mxcl/Chalk
2 |
3 | public enum Color: RawRepresentable {
4 | case black
5 | case red
6 | case green
7 | case yellow
8 | case blue
9 | case magenta
10 | case cyan
11 | case white
12 | case extended(UInt8)
13 |
14 | public init?(rawValue: UInt8) {
15 | switch rawValue {
16 | case 0: self = .black
17 | case 1: self = .red
18 | case 2: self = .green
19 | case 3: self = .yellow
20 | case 4: self = .blue
21 | case 5: self = .magenta
22 | case 6: self = .cyan
23 | case 7: self = .white
24 | default: self = .extended(rawValue)
25 | }
26 | }
27 |
28 | public var rawValue: UInt8 {
29 | switch self {
30 | case .black: return 0
31 | case .red: return 1
32 | case .green: return 2
33 | case .yellow: return 3
34 | case .blue: return 4
35 | case .magenta: return 5
36 | case .cyan: return 6
37 | case .white: return 7
38 | case .extended(let number): return number
39 | }
40 | }
41 | }
42 |
43 | public struct Style: OptionSet {
44 | public static let bold = Style(rawValue: 1 << 0)
45 | public static let dim = Style(rawValue: 1 << 1)
46 | public static let italic = Style(rawValue: 1 << 2)
47 | public static let underlined = Style(rawValue: 1 << 3)
48 | public static let blink = Style(rawValue: 1 << 4)
49 | public static let inverse = Style(rawValue: 1 << 5)
50 | public static let hidden = Style(rawValue: 1 << 6) // for eg. passwords
51 | public static let strikethrough = Style(rawValue: 1 << 7) // not implemented in Terminal.app
52 | public let rawValue: Int
53 |
54 | public init(rawValue: Int) {
55 | self.rawValue = rawValue
56 | }
57 | }
58 |
59 | private extension String.StringInterpolation {
60 | mutating func applyChalk(color: Color?, background: Color?, style: Style?, to any: Any) {
61 | appendLiteral("\u{001B}[")
62 |
63 | var codeStrings: [String] = []
64 |
65 | if let color = color?.rawValue {
66 | codeStrings.append("38")
67 | codeStrings.append("5")
68 | codeStrings.append("\(color)")
69 | }
70 |
71 | if let background = background?.rawValue {
72 | codeStrings.append("48")
73 | codeStrings.append("5")
74 | codeStrings.append("\(background)")
75 | }
76 |
77 | if let style = style {
78 | let lookups: [(Style, Int)] = [(.bold, 1), (.dim, 2), (.italic, 3), (.underlined, 4), (.blink, 5), (.inverse, 7), (.hidden, 8), (.strikethrough, 9)]
79 | for (key, value) in lookups where style.contains(key) {
80 | codeStrings.append(String(value))
81 | }
82 | }
83 |
84 | appendInterpolation(codeStrings.joined(separator: ";"))
85 |
86 | appendLiteral("m")
87 | appendInterpolation("\(any)")
88 | appendLiteral("\u{001B}[0m") // reset color, background, and style
89 | }
90 | }
91 |
92 | public extension String.StringInterpolation {
93 | mutating func appendInterpolation(_ any: Any, color: Color) {
94 | applyChalk(color: color, background: nil, style: nil, to: any)
95 | }
96 |
97 | mutating func appendInterpolation(_ any: Any, background: Color) {
98 | applyChalk(color: nil, background: background, style: nil, to: any)
99 | }
100 |
101 | mutating func appendInterpolation(_ any: Any, style: Style) {
102 | applyChalk(color: nil, background: nil, style: style, to: any)
103 | }
104 |
105 | mutating func appendInterpolation(_ any: Any, color: Color, background: Color) {
106 | applyChalk(color: color, background: background, style: nil, to: any)
107 | }
108 |
109 | mutating func appendInterpolation(_ any: Any, background: Color, style: Style) {
110 | applyChalk(color: nil, background: background, style: style, to: any)
111 | }
112 |
113 | mutating func appendInterpolation(_ any: Any, color: Color, style: Style) {
114 | applyChalk(color: color, background: nil, style: style, to: any)
115 | }
116 |
117 | mutating func appendInterpolation(_ any: Any, color: Color, background: Color, style: Style) {
118 | applyChalk(color: color, background: background, style: style, to: any)
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Defaults.swift:
--------------------------------------------------------------------------------
1 | public enum Defaults {
2 | public static let encryption = Cipher.xor
3 | public static let input = Input.environmentVariable
4 | }
5 |
6 | public extension Defaults {
7 | enum Swift {
8 | public static let typeName = "Secrets"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Engine.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct Engine {
4 | public init() {}
5 |
6 | public func createFiles(configuration: Configuration) {
7 | logger.log(.variableFetcher, "Resolving input variables...")
8 | resolve(declarations: configuration.secrets, input: configuration.input) { result in
9 | switch result {
10 | case let .success(secrets):
11 | for output in configuration.outputs {
12 | do {
13 | logger.log(.variableFetcher, "Input variables resolved successfully!")
14 | logger.log(.fileWriter, "Generating file output at \(output.file.filePath, color: .green)...")
15 | let contents = try generateFileContents(secrets: secrets, output: output, logger: logger)
16 | try write(fileContents: contents, to: output.file)
17 | logger.log(.fileWriter, "Generated file output at \(output.file.filePath, color: .green) successfully!")
18 | } catch {
19 | logger.log(.fileWriter, "Couldn't generate file output at \(output.file.filePath, color: .green): \(error, color: .red)")
20 | }
21 | }
22 | case let .failure(error):
23 | logger.log(.variableFetcher, "Couldn't retrieve input variables: \(error, color: .red)")
24 | }
25 | }
26 | }
27 |
28 | public func resolve(declarations: [SecretDeclaration], input: Input, completion: (Result<[Secret], Error>) -> Void) {
29 | switch input {
30 | case .environmentVariable:
31 | EnvironmentVariableFetcher().fetch(secrets: declarations, completion: completion)
32 | }
33 | }
34 |
35 | public func generateFileContents(secrets: [Secret], output: Output, logger: Logging) throws -> String {
36 | switch output.outputLanguage {
37 | case let .swift(swiftConfig):
38 | return SwiftGenerator().generateFileContents(secrets: secrets, config: swiftConfig)
39 | }
40 | }
41 |
42 | private func write(fileContents contents: String, to file: DecryptionFile) throws {
43 | let url = URL(fileURLWithPath: file.filePath)
44 | try contents.write(to: url, atomically: true, encoding: .utf8)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Extensions/Collection.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Collection {
4 | var isNotEmpty: Bool {
5 | return !isEmpty
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Extensions/Sequence.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Sequence {
4 | /// Beware! This isn't requiring the Hashable protocol for Element,
5 | /// and so finding unique element with this helper method is quite complex - O(n*n)
6 | func unique(identifying: (Element, Element) -> Bool) -> [Element] {
7 | var results = [Element]()
8 | for item in self where !results.contains(where: { identifying($0, item) }) {
9 | results.append(item)
10 | }
11 | return results
12 | }
13 | }
14 |
15 | extension Sequence where Element: Hashable {
16 | func unique() -> [Element] {
17 | return Array(Set(self))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Extensions/String.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension String {
4 | func uppercaseFirstLetter() -> String {
5 | guard isNotEmpty else { return self }
6 |
7 | return prefix(1).uppercased() + dropFirst().lowercased()
8 | }
9 |
10 | func toCamelCase() -> String {
11 | let parts = components(separatedBy: CharacterSet.alphanumerics.inverted)
12 |
13 | guard let firstPart = parts.first?.lowercased() else { return self }
14 |
15 | let otherParts = Array(parts.dropFirst().map { $0.uppercaseFirstLetter() })
16 |
17 | return [[firstPart], otherParts].flatMap { $0 }.joined(separator: "")
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Extensions/UInt8.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Array where Element == UInt8 {
4 | static func random(length: Int) -> [UInt8] {
5 | return (0...length).map { _ in UInt8.random(in: UInt8.min...UInt8.max) }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Extensions/URL.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL {
4 | public static var documentsDirectory: URL {
5 | return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
6 | }
7 |
8 | public static var temporaryDirectory: URL {
9 | if #available(OSX 10.12, *) {
10 | return FileManager.default.temporaryDirectory
11 | } else {
12 | return documentsDirectory
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Generators/Swift/SwiftCipherContentsGenerating.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol SwiftCipherContentsGenerating {
4 | func variableValue(for secret: Secret, config: SwiftConfig) -> String
5 | func neededImports() -> [String]
6 | func neededHelperFunctions() -> [String]
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Generators/Swift/SwiftGenerator.swift:
--------------------------------------------------------------------------------
1 | public struct SwiftGenerator {
2 | private struct SecretVariable {
3 | let name: String
4 | let type: String
5 | let value: String
6 |
7 | func toFullDeclaration() -> String {
8 | return "static let \(name): \(type) = \(value)"
9 | }
10 | }
11 |
12 | public init() {}
13 |
14 | public func generateFileContents(secrets: [Secret], config: SwiftConfig) -> String {
15 | var imports = [String]()
16 | var functions = [String]()
17 | var variables = [SecretVariable]()
18 |
19 | for secret in secrets {
20 | let cipher = cipherGenerator(for: secret)
21 | let value = cipher.variableValue(for: secret, config: config)
22 | let variable = SecretVariable(
23 | name: secret.generatedName ?? secret.name.toCamelCase(),
24 | type: "String",
25 | value: value
26 | )
27 |
28 | imports.append(contentsOf: cipher.neededImports())
29 | functions.append(contentsOf: cipher.neededHelperFunctions())
30 | variables.append(variable)
31 | }
32 |
33 | return
34 | """
35 | // swiftlint:disable all
36 | // Generated using Pouch — https://github.com/sunshinejr/Pouch
37 |
38 | \(imports.unique().map { "import \($0)" }.joined(separator: "\n"))
39 |
40 | enum \(config.typeName) {
41 | \(variables.map { " " + $0.toFullDeclaration() }.joined(separator: "\n"))
42 |
43 | \(functions.unique().joined(separator: "\n\n"))
44 | }\n
45 | """
46 | }
47 |
48 | private func cipherGenerator(for secret: Secret) -> SwiftCipherContentsGenerating {
49 | switch secret.encryption {
50 | case .xor:
51 | return SwiftXorGenerator()
52 | }
53 | }
54 | }
55 |
56 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Generators/Swift/SwiftXorGenerator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct SwiftXorGenerator: SwiftCipherContentsGenerating {
4 | public func variableValue(for secret: Secret, config: SwiftConfig) -> String {
5 | let salt = [UInt8].random(length: 64)
6 | let xoredValue = xorEncode(secret: secret.value, salt: salt)
7 | return "\(config.typeName)._xored(\(xoredValue), salt: \(salt))"
8 | }
9 |
10 | public func neededHelperFunctions() -> [String] {
11 | return
12 | ["""
13 | private static func _xored(_ secret: [UInt8], salt: [UInt8]) -> String {
14 | return String(bytes: secret.enumerated().map { index, character in
15 | return character ^ salt[index % salt.count]
16 | }, encoding: .utf8) ?? ""
17 | }
18 | """]
19 | }
20 |
21 | public func neededImports() -> [String] {
22 | return ["Foundation"]
23 | }
24 |
25 | private func xorEncode(secret: String, salt: [UInt8]) -> [UInt8] {
26 | let secretBytes = [UInt8](secret.utf8)
27 | return secretBytes.enumerated().map { index, character in
28 | return character ^ salt[index % salt.count]
29 | }
30 | }
31 |
32 | private func xorDecode(encoded secretBytes: [UInt8], salt: [UInt8]) -> String {
33 | return String(bytes: secretBytes.enumerated().map { index, character in
34 | return character ^ salt[index % salt.count]
35 | }, encoding: .utf8) ?? ""
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Logger.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public var logger: Logging = Logger(output: .disabled)
4 |
5 | public extension LogCategory {
6 | static let parser = LogCategory(name: "Parser")
7 | static let variableFetcher = LogCategory(name: "VariableFetcher")
8 | static let fileWriter = LogCategory(name: "FileWriter")
9 | }
10 |
11 | public protocol Logging {
12 | func log(_ message: String)
13 | func log(_ category: LogCategory, _ message: String)
14 | }
15 |
16 | public struct LogCategory {
17 | public let name: String
18 |
19 | public init(name: String) {
20 | self.name = name
21 | }
22 | }
23 |
24 | public struct Logger: Logging {
25 | public enum Output {
26 | case print
27 | case disabled
28 | }
29 |
30 | public var output: Output
31 |
32 | public init(output: Output) {
33 | self.output = output
34 | }
35 |
36 | public func log(_ category: LogCategory, _ message: String) {
37 | _log(category: category, message)
38 | }
39 |
40 | public func log(_ message: String) {
41 | _log(message)
42 | }
43 |
44 | private func _log(category: LogCategory? = nil, _ message: String) {
45 | switch output {
46 | case .print:
47 | if let category = category {
48 | print("\("[\(category.name)]", color: .yellow) \(message)")
49 | } else {
50 | print("\(message)")
51 | }
52 | case .disabled:
53 | break
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Cipher.swift:
--------------------------------------------------------------------------------
1 | public enum Cipher: String, Codable, Equatable {
2 | case xor
3 | }
4 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Configuration.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct Configuration: Codable, Equatable {
4 | public let input: Input
5 | public let secrets: [SecretDeclaration]
6 | public let outputs: [Output]
7 |
8 | public init(input: Input, secrets: [SecretDeclaration], outputs: [Output]) {
9 | self.input = input
10 | self.secrets = secrets
11 | self.outputs = outputs
12 | }
13 |
14 | public init(from decoder: Decoder) throws {
15 | let container = try decoder.container(keyedBy: CodingKeys.self)
16 | self.input = (try container.decodeIfPresent(Input.self, forKey: .input)) ?? Defaults.input
17 | self.secrets = try container.decode([SecretDeclaration].self, forKey: .secrets)
18 | self.outputs = try container.decode([Output].self, forKey: .outputs)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/DecryptionFile.swift:
--------------------------------------------------------------------------------
1 | public struct DecryptionFile: Codable, Equatable {
2 | public let filePath: String
3 |
4 | enum CodingKeys: String, CodingKey {
5 | case filePath
6 | }
7 |
8 | public init(filePath: String) {
9 | self.filePath = filePath
10 | }
11 |
12 | public init(from decoder: Decoder) throws {
13 | if let container = try? decoder.singleValueContainer(), let filePath = try? container.decode(String.self) {
14 | self.init(filePath: filePath)
15 | } else {
16 | let container = try decoder.container(keyedBy: CodingKeys.self)
17 | let filePath = try container.decode(String.self, forKey: .filePath)
18 | self.init(filePath: filePath)
19 | }
20 | }
21 |
22 | public func encode(to encoder: Encoder) throws {
23 | var container = encoder.singleValueContainer()
24 | try container.encode(filePath)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Input.swift:
--------------------------------------------------------------------------------
1 | public enum Input: String, Codable, Equatable {
2 | case environmentVariable
3 |
4 | enum CodingKeys: String, CodingKey {
5 | case environmentVariable = "env"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/LanguageOutput.swift:
--------------------------------------------------------------------------------
1 | enum OutputLanguageError: Error {
2 | case languageNotSupported(String)
3 | }
4 |
5 | public enum OutputLanguage: Codable, Equatable {
6 | case swift(SwiftConfig)
7 |
8 | enum CodingKeys: String, CodingKey {
9 | case language
10 | case config
11 | }
12 |
13 | public init(from decoder: Decoder) throws {
14 | if let container = try? decoder.container(keyedBy: CodingKeys.self) {
15 | let language = try container.decodeIfPresent(String.self, forKey: .language)
16 |
17 | switch language {
18 | case "swift":
19 | let config = try SwiftConfig(from: decoder)
20 | self = .swift(config)
21 | default:
22 | if let language = language, language.isNotEmpty {
23 | logger.log(.parser, "Language \"\(language)\" not supported, falling back to \"swift\"")
24 | }
25 | }
26 | }
27 |
28 | let config = try SwiftConfig(from: decoder)
29 | self = .swift(config)
30 | }
31 |
32 | public func encode(to encoder: Encoder) throws {
33 | var container = encoder.container(keyedBy: CodingKeys.self)
34 |
35 | switch self {
36 | case let .swift(config):
37 | try container.encode("swift", forKey: .language)
38 | try config.encode(to: encoder)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Languages/SwiftConfig.swift:
--------------------------------------------------------------------------------
1 | public struct SwiftConfig: Codable, Equatable {
2 | public let typeName: String
3 |
4 | enum CodingKeys: String, CodingKey {
5 | case typeName
6 | }
7 |
8 | public init(typeName: String) {
9 | self.typeName = typeName
10 | }
11 |
12 | public init(from decoder: Decoder) throws {
13 | if let container = try? decoder.container(keyedBy: CodingKeys.self) {
14 | self.typeName = (try? container.decode(String.self, forKey: .typeName)) ?? Defaults.Swift.typeName
15 | } else {
16 | self.typeName = Defaults.Swift.typeName
17 | }
18 | }
19 |
20 | public func encode(to encoder: Encoder) throws {
21 | var container = encoder.container(keyedBy: CodingKeys.self)
22 | try container.encode(typeName, forKey: .typeName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Output.swift:
--------------------------------------------------------------------------------
1 | public struct Output: Codable, Equatable {
2 | public let file: DecryptionFile
3 | public let outputLanguage: OutputLanguage
4 |
5 | public init(decryptionFile: DecryptionFile, outputLanguage: OutputLanguage) {
6 | self.file = decryptionFile
7 | self.outputLanguage = outputLanguage
8 | }
9 |
10 | public init(from decoder: Decoder) throws {
11 | file = try DecryptionFile(from: decoder)
12 | outputLanguage = try OutputLanguage(from: decoder)
13 | }
14 |
15 | public func encode(to encoder: Encoder) throws {
16 | try file.encode(to: encoder)
17 | try outputLanguage.encode(to: encoder)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/Secret.swift:
--------------------------------------------------------------------------------
1 | public struct Secret {
2 | public let name: String
3 | public let generatedName: String?
4 | public let value: String
5 | public let encryption: Cipher
6 |
7 | public init(name: String, generatedName: String?, value: String, encryption: Cipher) {
8 | self.name = name
9 | self.generatedName = generatedName
10 | self.value = value
11 | self.encryption = encryption
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/Models/SecretDeclaration.swift:
--------------------------------------------------------------------------------
1 | public struct SecretDeclaration: Codable, Equatable {
2 | public let name: String
3 | public let generatedName: String?
4 | public let encryption: Cipher
5 |
6 | enum CodingKeys: String, CodingKey {
7 | case name
8 | case generatedName
9 | case encryption
10 | }
11 |
12 | public init(name: String, generatedName: String? = nil, encryption: Cipher) {
13 | self.name = name
14 | self.generatedName = generatedName
15 | self.encryption = encryption
16 | }
17 |
18 | public init(from decoder: Decoder) throws {
19 | if let container = try? decoder.singleValueContainer(), let name = try? container.decode(String.self) {
20 | self.init(name: name, encryption: Defaults.encryption)
21 | } else {
22 | let container = try decoder.container(keyedBy: CodingKeys.self)
23 | let name = try container.decode(String.self, forKey: .name)
24 | let generatedName = try container.decodeIfPresent(String.self, forKey: .generatedName)
25 | let encryption = (try container.decodeIfPresent(Cipher.self, forKey: .encryption)) ?? Defaults.encryption
26 | self.init(name: name, generatedName: generatedName, encryption: encryption)
27 | }
28 | }
29 |
30 | public func encode(to encoder: Encoder) throws {
31 | if encryption == Defaults.encryption {
32 | var container = encoder.singleValueContainer()
33 | try container.encode(name)
34 | } else {
35 | var container = encoder.container(keyedBy: CodingKeys.self)
36 | try container.encode(name, forKey: .name)
37 | try container.encodeIfPresent(generatedName, forKey: .generatedName)
38 | try container.encode(encryption, forKey: .encryption)
39 | }
40 | }
41 | }
42 |
43 | extension SecretDeclaration {
44 | func with(value: String) -> Secret {
45 | return .init(name: name, generatedName: generatedName, value: value, encryption: encryption)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/VariableFetchers/EnvironmentVariablesFetcher.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct EnvironmentVariableFetcher: VariableFetching {
4 | public func fetch(secrets: [SecretDeclaration], completion: (Result<[Secret], Error>) -> Void) {
5 | let environment = ProcessInfo.processInfo.environment
6 | var resolvedSecrets = [Secret]()
7 | for secret in secrets {
8 | guard let value = environment[secret.name] else {
9 | completion(.failure(VariableFetchingError.variableNotFound(name: secret.name, input: .environmentVariable)))
10 | return
11 | }
12 |
13 | resolvedSecrets.append(secret.with(value: value))
14 | }
15 |
16 | completion(.success(resolvedSecrets))
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/VariableFetchers/VariableFetching.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol VariableFetching {
4 | func fetch(secrets: [SecretDeclaration], completion: (Result<[Secret], Error>) -> Void)
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/PouchFramework/VariableFetchers/VariableFetchingError.swift:
--------------------------------------------------------------------------------
1 | enum VariableFetchingError: Error {
2 | case variableNotFound(name: String, input: Input)
3 | }
4 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import PouchTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += PouchTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/Tests/PouchTests/Generators/SwiftGeneratorTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import PouchFramework
3 |
4 | final class SwiftGeneratorTests: XCTestCase {
5 | func test_generatedOutput() throws {
6 | let secret = Secret(name: "API_KEYY", generatedName: "apiKey", value: "secret_sauce_monke_boi🐒", encryption: .xor)
7 | let config = SwiftConfig(typeName: "Sauce")
8 | let contents = SwiftGenerator().generateFileContents(secrets: [secret], config: config)
9 | let contentsWithPrints = contents + "\n print(\(config.typeName).\(secret.generatedName!))"
10 | let file = try contentsWithPrints.saveToTemporaryDirectory()
11 | let output = try Process.run(tool: .swift, arguments: [file.path])
12 |
13 | XCTAssertEqual(output, secret.value)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/PouchTests/Parsers/ConfigurationParsingTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import PouchFramework
3 | import Yams
4 |
5 | final class ConfigurationParsingTests: XCTestCase {
6 | func test_noOutput_noConfiguration() {
7 | let config =
8 | """
9 | secrets:
10 | - API_KEY
11 | """
12 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8))
13 | XCTAssertNil(parsedConfiguration)
14 | }
15 |
16 | func test_noSecrets_noConfiguration() {
17 | let config =
18 | """
19 | outputs:
20 | - ./Secrets.swift
21 | """
22 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8))
23 | XCTAssertNil(parsedConfiguration)
24 | }
25 |
26 | func test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully() {
27 | let config =
28 | """
29 | secrets:
30 | - API_KEY
31 | outputs:
32 | - ./Secrets.swift
33 | """
34 | let parsedConfiguration = try? YAMLDecoder().decode(Configuration.self, from: Data(config.utf8))
35 | let expectedConfiguration = Configuration(
36 | input: Defaults.input,
37 | secrets: [
38 | .init(name: "API_KEY", encryption: Defaults.encryption)
39 | ],
40 | outputs: [
41 | .init(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName)))
42 | ])
43 | XCTAssertEqual(parsedConfiguration, expectedConfiguration)
44 | }
45 | }
46 |
47 | extension ConfigurationParsingTests {
48 | static var allTests = [
49 | ("test_noOutput_noConfiguration", test_noOutput_noConfiguration),
50 | ("test_noSecrets_noConfiguration", test_noSecrets_noConfiguration),
51 | ("test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully", test_hasAtLeastOneSecretAndOneOutput_parsesSuccessfully)
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/Tests/PouchTests/Parsers/OutputParsingTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import PouchFramework
3 | import Yams
4 |
5 | final class OutputParsingTests: XCTestCase {
6 | func test_namedParameters_withFilePathOnly_parsesSuccessfully() {
7 | let config =
8 | """
9 | - filePath: ./Secrets.swift
10 | """
11 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8))
12 | let expectedOutputs = [
13 | Output(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName)))
14 | ]
15 | XCTAssertEqual(parsedOutputs, expectedOutputs)
16 | }
17 |
18 | func test_namedParameters_withFilePathAndTypeName_parsesSuccessfully() {
19 | let config =
20 | """
21 | - filePath: ./Sauce.swift
22 | typeName: Sauce
23 | """
24 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8))
25 | let expectedOutputs = [
26 | Output(decryptionFile: .init(filePath: "./Sauce.swift"), outputLanguage: .swift(.init(typeName: "Sauce")))
27 | ]
28 | XCTAssertEqual(parsedOutputs, expectedOutputs)
29 | }
30 |
31 | func test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully() {
32 | let config =
33 | """
34 | - ./Secrets.swift
35 | - filePath: ./Sauce.swift
36 | typeName: Sauce
37 | """
38 | let parsedOutputs = try? YAMLDecoder().decode([Output].self, from: Data(config.utf8))
39 | let expectedOutputs = [
40 | Output(decryptionFile: .init(filePath: "./Secrets.swift"), outputLanguage: .swift(.init(typeName: Defaults.Swift.typeName))),
41 | Output(decryptionFile: .init(filePath: "./Sauce.swift"), outputLanguage: .swift(.init(typeName: "Sauce")))
42 | ]
43 | XCTAssertEqual(parsedOutputs, expectedOutputs)
44 | }
45 | }
46 |
47 | extension OutputParsingTests {
48 | static var allTests = [
49 | ("test_namedParameters_withFilePathOnly_parsesSuccessfully", test_namedParameters_withFilePathOnly_parsesSuccessfully),
50 | ("test_namedParameters_withFilePathAndTypeName_parsesSuccessfully", test_namedParameters_withFilePathAndTypeName_parsesSuccessfully),
51 | ("test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully", test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully)
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/Tests/PouchTests/Parsers/SecretParsingTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import PouchFramework
3 | import Yams
4 |
5 | final class SecretParsingTests: XCTestCase {
6 | func test_namedParameters_withNameOnly_parsesSuccessfully() {
7 | let config =
8 | """
9 | - name: API_KEY2
10 | """
11 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8))
12 | let expectedSecrets = [SecretDeclaration(name: "API_KEY2", encryption: Defaults.encryption)]
13 | XCTAssertEqual(parsedSecrets, expectedSecrets)
14 | }
15 |
16 |
17 | func test_namedParameters_withNameAndGeneratedName_parsesSuccessfully() {
18 | let config =
19 | """
20 | - name: API_KEY2
21 | generatedName: apiKeyuu
22 | """
23 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8))
24 | let expectedSecrets = [SecretDeclaration(name: "API_KEY2", generatedName: "apiKeyuu", encryption: Defaults.encryption)]
25 | XCTAssertEqual(parsedSecrets, expectedSecrets)
26 | }
27 |
28 | func test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully() {
29 | let config =
30 | """
31 | - API_KEY
32 | - name: API_KEY2
33 | generatedName: apiKeyuu
34 | """
35 | let parsedSecrets = try? YAMLDecoder().decode([SecretDeclaration].self, from: Data(config.utf8))
36 | let expectedSecrets = [
37 | SecretDeclaration(name: "API_KEY", encryption: Defaults.encryption),
38 | SecretDeclaration(name: "API_KEY2", generatedName: "apiKeyuu", encryption: Defaults.encryption)
39 | ]
40 | XCTAssertEqual(parsedSecrets, expectedSecrets)
41 | }
42 | }
43 |
44 | extension SecretParsingTests {
45 | static var allTests = [
46 | ("test_namedParameters_withNameOnly_parsesSuccessfully", test_namedParameters_withNameOnly_parsesSuccessfully),
47 | ("test_namedParameters_withNameAndGeneratedName_parsesSuccessfully", test_namedParameters_withNameAndGeneratedName_parsesSuccessfully),
48 | ("test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully", test_multipleSecrets_withBothSingleStringAndNamedParameters_parsesSuccessfully)
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/Tests/PouchTests/PouchTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import PouchFramework
3 |
4 | final class PouchTests: XCTestCase {
5 | func test_showsHelp() throws {
6 | let output = try Process.run(tool: .pouch, arguments: ["-h"])
7 | XCTAssertTrue(output.contains("A utility tool for secret management"))
8 | }
9 |
10 | func test_generatesFileBasedOnConfig() throws {
11 | let secretApiKey = "secret_sauce_monke_boi🐒"
12 | let generatedFileUrl = URL(fileURLWithPath: "./Secrets2.swift")
13 | let config = """
14 | secrets:
15 | - name: API_KEY_4
16 | generatedName: apiKey4
17 | outputs:
18 | - filePath: ./Secrets2.swift
19 | typeName: Sauce
20 | """
21 | let configFile = try config.saveToTemporaryDirectory()
22 | try Process.run(tool: .pouch, arguments: ["retrieve", "--config", configFile.path], environmentVariables: ["API_KEY_4": secretApiKey])
23 | let generatedFileContents = try String(contentsOfFile: generatedFileUrl.path)
24 | let generatedFileContentsWithPrint = generatedFileContents + "\n print(Sauce.apiKey4)"
25 | try generatedFileContentsWithPrint.write(toFile: generatedFileUrl.path, atomically: true, encoding: .utf8)
26 |
27 | let output = try Process.run(tool: .swift, arguments: [generatedFileUrl.path])
28 | XCTAssertEqual(output, secretApiKey)
29 | }
30 |
31 | static var allTests = [
32 | ("test_showHelpOnMainCall", test_showsHelp),
33 | ("test_generatesFileBasedOnConfig", test_generatesFileBasedOnConfig)
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/Tests/PouchTests/Utilities.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL {
4 | /// Returns path to the built products directory.
5 | public static var productsDirectory: URL {
6 | #if os(macOS)
7 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
8 | return bundle.bundleURL.deletingLastPathComponent()
9 | }
10 | fatalError("couldn't find the products directory")
11 | #else
12 | return Bundle.main.bundleURL
13 | #endif
14 | }
15 | }
16 |
17 | extension String {
18 | func removing(suffix: String) -> String {
19 | guard hasSuffix(suffix) else { return self }
20 |
21 | let endIndex = index(self.endIndex, offsetBy: -suffix.count)
22 | return String(self[.. URL {
26 | let url = URL.documentsDirectory.appendingPathComponent("Temp\(Int.random(in: 0...10_000))")
27 | try write(to: url, atomically: true, encoding: .utf8)
28 | return url
29 | }
30 | }
31 |
32 | enum Tool {
33 | case swift
34 | case pouch
35 |
36 | var executableURL: URL {
37 | switch self {
38 | case .swift:
39 | return URL(fileURLWithPath: "/usr/bin/swift")
40 | case .pouch:
41 | return URL.productsDirectory.appendingPathComponent("Pouch")
42 | }
43 | }
44 | }
45 |
46 | extension Process {
47 | @discardableResult
48 | static func run(tool: Tool, arguments: [String]? = nil, environmentVariables: [String: String]? = nil) throws -> String {
49 | let process = Process()
50 | process.executableURL = tool.executableURL
51 | process.environment = ProcessInfo.processInfo.environment
52 | for (key, value) in environmentVariables ?? [:] {
53 | process.environment?[key] = value
54 | }
55 | process.arguments = arguments
56 |
57 | let pipe = Pipe()
58 | process.standardOutput = pipe
59 |
60 | try process.run()
61 | process.waitUntilExit()
62 |
63 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
64 | return (String(data: data, encoding: .utf8) ?? "").removing(suffix: "\n")
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Tests/PouchTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(PouchTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------