Define configuration and CLI contracts (#2)
This commit is contained in:
278
Cargo.lock
generated
278
Cargo.lock
generated
@@ -800,7 +800,7 @@ dependencies = [
|
|||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
"libc",
|
"libc",
|
||||||
"shlex",
|
"shlex 2.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1458,6 +1458,17 @@ dependencies = [
|
|||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "displaydoc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlib"
|
name = "dlib"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1846,6 +1857,15 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs-err"
|
name = "fs-err"
|
||||||
version = "3.3.1"
|
version = "3.3.1"
|
||||||
@@ -2436,6 +2456,88 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idea"
|
name = "idea"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2451,6 +2553,27 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -2498,6 +2621,7 @@ dependencies = [
|
|||||||
name = "ironstorage"
|
name = "ironstorage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
"hex",
|
"hex",
|
||||||
"pgp",
|
"pgp",
|
||||||
@@ -2505,9 +2629,11 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"shlex 1.3.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2522,8 +2648,8 @@ dependencies = [
|
|||||||
name = "ironstorage-cli"
|
name = "ironstorage-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
|
||||||
"ironstorage",
|
"ironstorage",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2789,6 +2915,12 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -3973,6 +4105,15 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -4617,6 +4758,12 @@ dependencies = [
|
|||||||
"keccak",
|
"keccak",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -4888,6 +5035,12 @@ dependencies = [
|
|||||||
"der",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "static_assertions"
|
name = "static_assertions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -4983,6 +5136,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sys-locale"
|
name = "sys-locale"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -5204,6 +5368,16 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -5549,6 +5723,23 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -6447,6 +6638,12 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wyz"
|
name = "wyz"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -6537,6 +6734,29 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.18.0"
|
version = "5.18.0"
|
||||||
@@ -6624,6 +6844,27 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -6644,6 +6885,39 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.7"
|
||||||
|
|||||||
@@ -19,4 +19,8 @@ crossterm = "0.29"
|
|||||||
iced = "0.14"
|
iced = "0.14"
|
||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
shlex = "1.3"
|
||||||
|
toml = "0.9"
|
||||||
uniffi = "0.32"
|
uniffi = "0.32"
|
||||||
|
url = { version = "2.5", default-features = false }
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ and `gpg`—are forbidden; compatibility is implemented in Rust.
|
|||||||
Candidate libraries and the pending project-license decision are tracked in
|
Candidate libraries and the pending project-license decision are tracked in
|
||||||
[`DEPENDENCIES.md`](DEPENDENCIES.md).
|
[`DEPENDENCIES.md`](DEPENDENCIES.md).
|
||||||
|
|
||||||
|
The shared TOML schema, path rules, editor precedence, and HTTPS remote format
|
||||||
|
are documented in [`docs/configuration.md`](docs/configuration.md).
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -11,5 +11,7 @@ name = "ironstorage"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap.workspace = true
|
|
||||||
ironstorage.workspace = true
|
ironstorage.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1,13 +1,173 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![deny(clippy::disallowed_types)]
|
#![deny(clippy::disallowed_types)]
|
||||||
|
|
||||||
use clap::Parser;
|
use std::{ffi::OsString, io::Write, process::ExitCode};
|
||||||
|
|
||||||
#[derive(Parser)]
|
use ironstorage::{
|
||||||
#[command(version, about = "A pass-compatible password-store client")]
|
command::{
|
||||||
struct Arguments {}
|
CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic,
|
||||||
|
OtpRequest, help_text, otp_version_text, parse_from, version_text,
|
||||||
|
},
|
||||||
|
config::Config,
|
||||||
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() -> ExitCode {
|
||||||
Arguments::parse();
|
match run() {
|
||||||
println!("{}", ironstorage::PRODUCT_NAME);
|
Ok(code) => ExitCode::from(code),
|
||||||
|
Err(()) => ExitCode::from(ironstorage::command::EXIT_FAILURE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<u8, ()> {
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
let stderr = std::io::stderr();
|
||||||
|
run_with(std::env::args_os(), stdout.lock(), stderr.lock())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_with<I, T, O, E>(arguments: I, mut stdout: O, mut stderr: E) -> Result<u8, ()>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = T>,
|
||||||
|
T: Into<OsString> + Clone,
|
||||||
|
O: Write,
|
||||||
|
E: Write,
|
||||||
|
{
|
||||||
|
let action = match parse_from(arguments) {
|
||||||
|
Ok(action) => action,
|
||||||
|
Err(error) => {
|
||||||
|
stderr
|
||||||
|
.write_all(error.to_string().as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
return Ok(error.exit_code());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
CliAction::Display(text) => {
|
||||||
|
stdout.write_all(text.as_bytes()).map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
CliAction::Run(invocation) => match invocation.request() {
|
||||||
|
CommandRequest::Help { topic } => {
|
||||||
|
stdout
|
||||||
|
.write_all(help_text(*topic).as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
CommandRequest::Version => {
|
||||||
|
stdout
|
||||||
|
.write_all(version_text().as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
CommandRequest::Otp(OtpRequest::Help) => {
|
||||||
|
stdout
|
||||||
|
.write_all(help_text(Some(HelpTopic::Otp)).as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
CommandRequest::Otp(OtpRequest::Version) => {
|
||||||
|
stdout
|
||||||
|
.write_all(otp_version_text().as_bytes())
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
_ => match Config::load(invocation.config()) {
|
||||||
|
Ok(_) => {
|
||||||
|
stderr
|
||||||
|
.write_all(
|
||||||
|
b"the command contract is valid, but this storage operation is not available yet\n",
|
||||||
|
)
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(EXIT_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
writeln!(stderr, "{error}").map_err(|_| ())?;
|
||||||
|
Ok(EXIT_CONFIG)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{error::Error, ffi::OsString, fs};
|
||||||
|
|
||||||
|
use ironstorage::command::{EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE};
|
||||||
|
|
||||||
|
use super::run_with;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult {
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
let code = run_with(["ironstorage", "--help"], &mut stdout, &mut stderr)
|
||||||
|
.expect("writing to memory cannot fail");
|
||||||
|
assert_eq!(code, EXIT_SUCCESS);
|
||||||
|
assert!(String::from_utf8(stdout)?.contains("Usage"));
|
||||||
|
assert!(stderr.is_empty());
|
||||||
|
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
let code = run_with(
|
||||||
|
["ironstorage", "insert", "--echo", "--multiline", "entry"],
|
||||||
|
&mut stdout,
|
||||||
|
&mut stderr,
|
||||||
|
)
|
||||||
|
.expect("writing to memory cannot fail");
|
||||||
|
assert_eq!(code, EXIT_USAGE);
|
||||||
|
assert!(stdout.is_empty());
|
||||||
|
assert!(String::from_utf8(stderr)?.contains("cannot be used with"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuration_and_unavailable_operations_have_stable_exit_codes() -> TestResult {
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
let missing = temporary.path().join("missing.toml");
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
let code = run_with(
|
||||||
|
[
|
||||||
|
OsString::from("ironstorage"),
|
||||||
|
OsString::from("--config"),
|
||||||
|
missing.into_os_string(),
|
||||||
|
OsString::from("show"),
|
||||||
|
OsString::from("entry"),
|
||||||
|
],
|
||||||
|
&mut stdout,
|
||||||
|
&mut stderr,
|
||||||
|
)
|
||||||
|
.expect("writing to memory cannot fail");
|
||||||
|
assert_eq!(code, EXIT_CONFIG);
|
||||||
|
assert!(stdout.is_empty());
|
||||||
|
assert!(String::from_utf8(stderr)?.contains("configuration file not found"));
|
||||||
|
|
||||||
|
let keys = temporary.path().join("keys");
|
||||||
|
fs::create_dir(&keys)?;
|
||||||
|
let config = temporary.path().join("config.toml");
|
||||||
|
fs::write(
|
||||||
|
&config,
|
||||||
|
"vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n",
|
||||||
|
)?;
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
let code = run_with(
|
||||||
|
[
|
||||||
|
OsString::from("ironstorage"),
|
||||||
|
OsString::from("--config"),
|
||||||
|
config.into_os_string(),
|
||||||
|
OsString::from("show"),
|
||||||
|
OsString::from("entry"),
|
||||||
|
],
|
||||||
|
&mut stdout,
|
||||||
|
&mut stderr,
|
||||||
|
)
|
||||||
|
.expect("writing to memory cannot fail");
|
||||||
|
assert_eq!(code, EXIT_UNAVAILABLE);
|
||||||
|
assert!(stdout.is_empty());
|
||||||
|
assert!(String::from_utf8(stderr)?.contains("not available yet"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,19 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
shlex.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
smallvec = "1.15"
|
smallvec = "1.15"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
toml = "0.9"
|
|
||||||
|
|||||||
1007
crates/storage/src/command.rs
Normal file
1007
crates/storage/src/command.rs
Normal file
File diff suppressed because it is too large
Load Diff
733
crates/storage/src/config.rs
Normal file
733
crates/storage/src/config.rs
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
//! Strict, secret-free configuration shared by every frontend.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
env,
|
||||||
|
error::Error,
|
||||||
|
fmt, fs,
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const APPLICATION_DIRECTORY: &str = "ironstorage";
|
||||||
|
const CONFIG_FILE: &str = "config.toml";
|
||||||
|
const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Validated application configuration.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Config {
|
||||||
|
source: PathBuf,
|
||||||
|
vault: PathBuf,
|
||||||
|
default_key: KeyIdentity,
|
||||||
|
key_material: PathBuf,
|
||||||
|
editor: Option<EditorCommand>,
|
||||||
|
git_remotes: Vec<GitRemote>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Load an explicit configuration file, or the native per-user default.
|
||||||
|
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
|
||||||
|
ConfigLoader::system()?.load(explicit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source(&self) -> &Path {
|
||||||
|
&self.source
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vault(&self) -> &Path {
|
||||||
|
&self.vault
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_key(&self) -> &KeyIdentity {
|
||||||
|
&self.default_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key_material(&self) -> &Path {
|
||||||
|
&self.key_material
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn configured_editor(&self) -> Option<&EditorCommand> {
|
||||||
|
self.editor.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn git_remotes(&self) -> &[GitRemote] {
|
||||||
|
&self.git_remotes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the configured editor, then `$VISUAL`, `$EDITOR`, and finally `vim`.
|
||||||
|
pub fn resolve_editor(&self) -> Result<ResolvedEditor, EditorError> {
|
||||||
|
self.resolve_editor_from(
|
||||||
|
env::var_os("VISUAL").as_deref(),
|
||||||
|
env::var_os("EDITOR").as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic editor resolution used by adapters and tests.
|
||||||
|
pub fn resolve_editor_from(
|
||||||
|
&self,
|
||||||
|
visual: Option<&std::ffi::OsStr>,
|
||||||
|
editor: Option<&std::ffi::OsStr>,
|
||||||
|
) -> Result<ResolvedEditor, EditorError> {
|
||||||
|
if let Some(command) = &self.editor {
|
||||||
|
return Ok(ResolvedEditor {
|
||||||
|
command: command.clone(),
|
||||||
|
source: EditorSource::Configuration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(command) = parse_environment_editor("VISUAL", visual)? {
|
||||||
|
return Ok(ResolvedEditor {
|
||||||
|
command,
|
||||||
|
source: EditorSource::VisualEnvironment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(command) = parse_environment_editor("EDITOR", editor)? {
|
||||||
|
return Ok(ResolvedEditor {
|
||||||
|
command,
|
||||||
|
source: EditorSource::EditorEnvironment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(ResolvedEditor {
|
||||||
|
command: EditorCommand {
|
||||||
|
program: "vim".to_owned(),
|
||||||
|
arguments: Vec::new(),
|
||||||
|
},
|
||||||
|
source: EditorSource::Fallback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic path context for configuration loading.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigLoader {
|
||||||
|
current_directory: PathBuf,
|
||||||
|
native_config_directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigLoader {
|
||||||
|
pub fn system() -> Result<Self, ConfigError> {
|
||||||
|
let current_directory = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
|
||||||
|
let native_config_directory =
|
||||||
|
native_config_directory().ok_or(ConfigError::NativeConfigDirectory)?;
|
||||||
|
Ok(Self::new(current_directory, native_config_directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(current_directory: PathBuf, native_config_directory: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
current_directory: normalize_absolute(current_directory),
|
||||||
|
native_config_directory: normalize_absolute(native_config_directory),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path(&self) -> PathBuf {
|
||||||
|
self.native_config_directory
|
||||||
|
.join(APPLICATION_DIRECTORY)
|
||||||
|
.join(CONFIG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self, explicit: Option<&Path>) -> Result<Config, ConfigError> {
|
||||||
|
let requested = explicit
|
||||||
|
.map(|path| resolve_path(&self.current_directory, path))
|
||||||
|
.unwrap_or_else(|| self.default_path());
|
||||||
|
let metadata = fs::metadata(&requested).map_err(|error| {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
ConfigError::NotFound {
|
||||||
|
path: requested.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ConfigError::Read {
|
||||||
|
path: requested.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
|
||||||
|
return Err(ConfigError::InvalidFile {
|
||||||
|
path: requested.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let source = fs::canonicalize(&requested).map_err(|_| ConfigError::Read {
|
||||||
|
path: requested.clone(),
|
||||||
|
})?;
|
||||||
|
let contents = fs::read_to_string(&source).map_err(|_| ConfigError::Read {
|
||||||
|
path: source.clone(),
|
||||||
|
})?;
|
||||||
|
let value =
|
||||||
|
toml::from_str::<toml::Value>(&contents).map_err(|_| ConfigError::Malformed {
|
||||||
|
path: source.clone(),
|
||||||
|
})?;
|
||||||
|
reject_insecure_fields(&value, "")?;
|
||||||
|
validate_known_fields(&value, &source)?;
|
||||||
|
let raw = value
|
||||||
|
.try_into::<RawConfig>()
|
||||||
|
.map_err(|_| ConfigError::Malformed {
|
||||||
|
path: source.clone(),
|
||||||
|
})?;
|
||||||
|
validate_config(source, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct KeyIdentity(String);
|
||||||
|
|
||||||
|
impl KeyIdentity {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for KeyIdentity {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! identifier_type {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier_type!(RemoteName);
|
||||||
|
identifier_type!(ServerId);
|
||||||
|
identifier_type!(ApplicationId);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct GitRemote {
|
||||||
|
name: RemoteName,
|
||||||
|
url: Url,
|
||||||
|
server_id: ServerId,
|
||||||
|
application_id: ApplicationId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitRemote {
|
||||||
|
pub fn name(&self) -> &RemoteName {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> &Url {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_id(&self) -> &ServerId {
|
||||||
|
&self.server_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn application_id(&self) -> &ApplicationId {
|
||||||
|
&self.application_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An executable and arguments. It is never interpreted by a shell.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct EditorCommand {
|
||||||
|
program: String,
|
||||||
|
arguments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorCommand {
|
||||||
|
pub fn program(&self) -> &str {
|
||||||
|
&self.program
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arguments(&self) -> &[String] {
|
||||||
|
&self.arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorSource {
|
||||||
|
Configuration,
|
||||||
|
VisualEnvironment,
|
||||||
|
EditorEnvironment,
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ResolvedEditor {
|
||||||
|
command: EditorCommand,
|
||||||
|
source: EditorSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedEditor {
|
||||||
|
pub fn command(&self) -> &EditorCommand {
|
||||||
|
&self.command
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn source(&self) -> EditorSource {
|
||||||
|
self.source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
CurrentDirectory,
|
||||||
|
NativeConfigDirectory,
|
||||||
|
NotFound { path: PathBuf },
|
||||||
|
Read { path: PathBuf },
|
||||||
|
InvalidFile { path: PathBuf },
|
||||||
|
Malformed { path: PathBuf },
|
||||||
|
UnknownField { field: String },
|
||||||
|
InsecureField { field: String },
|
||||||
|
MissingField { field: &'static str },
|
||||||
|
InvalidField { field: &'static str },
|
||||||
|
VaultIsNotDirectory { path: PathBuf },
|
||||||
|
KeyMaterialNotFound { path: PathBuf },
|
||||||
|
InvalidKeyMaterial { path: PathBuf },
|
||||||
|
DuplicateRemote { name: String },
|
||||||
|
DuplicateCredentialReference,
|
||||||
|
InvalidRemoteUrl { name: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::CurrentDirectory => formatter.write_str("cannot determine the current directory"),
|
||||||
|
Self::NativeConfigDirectory => {
|
||||||
|
formatter.write_str("cannot determine the native user configuration directory")
|
||||||
|
}
|
||||||
|
Self::NotFound { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"configuration file not found: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Read { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"cannot read configuration file: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidFile { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"configuration path is not a small regular file: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::Malformed { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"configuration is malformed; its contents were redacted: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::UnknownField { field } => {
|
||||||
|
write!(formatter, "unknown configuration field: {field}")
|
||||||
|
}
|
||||||
|
Self::InsecureField { field } => write!(
|
||||||
|
formatter,
|
||||||
|
"secret values are forbidden in configuration field: {field}"
|
||||||
|
),
|
||||||
|
Self::MissingField { field } => {
|
||||||
|
write!(formatter, "missing required configuration field: {field}")
|
||||||
|
}
|
||||||
|
Self::InvalidField { field } => {
|
||||||
|
write!(formatter, "invalid configuration field: {field}")
|
||||||
|
}
|
||||||
|
Self::VaultIsNotDirectory { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"vault path is not a directory: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::KeyMaterialNotFound { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"exported key material does not exist: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::InvalidKeyMaterial { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"exported key material is not a file or directory: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::DuplicateRemote { name } => {
|
||||||
|
write!(formatter, "duplicate Git remote name: {name}")
|
||||||
|
}
|
||||||
|
Self::DuplicateCredentialReference => {
|
||||||
|
formatter.write_str("duplicate Git server/application credential reference")
|
||||||
|
}
|
||||||
|
Self::InvalidRemoteUrl { name } => write!(
|
||||||
|
formatter,
|
||||||
|
"Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for ConfigError {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum EditorError {
|
||||||
|
NonUnicode { variable: &'static str },
|
||||||
|
InvalidCommand { source: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EditorError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NonUnicode { variable } => {
|
||||||
|
write!(formatter, "{variable} is not valid Unicode")
|
||||||
|
}
|
||||||
|
Self::InvalidCommand { source } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"{source} does not contain a valid editor command"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for EditorError {}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RawConfig {
|
||||||
|
vault: Option<PathBuf>,
|
||||||
|
default_key: Option<String>,
|
||||||
|
key_material: Option<PathBuf>,
|
||||||
|
editor: Option<RawEditor>,
|
||||||
|
#[serde(default)]
|
||||||
|
git: RawGit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RawEditor {
|
||||||
|
CommandLine(String),
|
||||||
|
Arguments(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RawGit {
|
||||||
|
#[serde(default)]
|
||||||
|
remotes: Vec<RawGitRemote>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RawGitRemote {
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
server_id: String,
|
||||||
|
application_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_config(source: PathBuf, raw: RawConfig) -> Result<Config, ConfigError> {
|
||||||
|
let base = source
|
||||||
|
.parent()
|
||||||
|
.ok_or(ConfigError::InvalidField { field: "source" })?;
|
||||||
|
let vault = resolve_required_path(base, raw.vault, "vault")?;
|
||||||
|
if vault.exists() && !vault.is_dir() {
|
||||||
|
return Err(ConfigError::VaultIsNotDirectory { path: vault });
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_material = resolve_required_path(base, raw.key_material, "key_material")?;
|
||||||
|
if !key_material.exists() {
|
||||||
|
return Err(ConfigError::KeyMaterialNotFound { path: key_material });
|
||||||
|
}
|
||||||
|
let key_metadata =
|
||||||
|
fs::metadata(&key_material).map_err(|_| ConfigError::KeyMaterialNotFound {
|
||||||
|
path: key_material.clone(),
|
||||||
|
})?;
|
||||||
|
if !key_metadata.is_file() && !key_metadata.is_dir() {
|
||||||
|
return Err(ConfigError::InvalidKeyMaterial { path: key_material });
|
||||||
|
}
|
||||||
|
let key_material = fs::canonicalize(&key_material)
|
||||||
|
.map_err(|_| ConfigError::InvalidKeyMaterial { path: key_material })?;
|
||||||
|
|
||||||
|
let default_key = raw
|
||||||
|
.default_key
|
||||||
|
.ok_or(ConfigError::MissingField {
|
||||||
|
field: "default_key",
|
||||||
|
})
|
||||||
|
.and_then(validate_key_identity)?;
|
||||||
|
let editor = raw.editor.map(validate_editor).transpose()?;
|
||||||
|
let git_remotes = validate_remotes(raw.git.remotes)?;
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
source,
|
||||||
|
vault,
|
||||||
|
default_key,
|
||||||
|
key_material,
|
||||||
|
editor,
|
||||||
|
git_remotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_required_path(
|
||||||
|
base: &Path,
|
||||||
|
value: Option<PathBuf>,
|
||||||
|
field: &'static str,
|
||||||
|
) -> Result<PathBuf, ConfigError> {
|
||||||
|
let value = value.ok_or(ConfigError::MissingField { field })?;
|
||||||
|
if value.as_os_str().is_empty() {
|
||||||
|
return Err(ConfigError::InvalidField { field });
|
||||||
|
}
|
||||||
|
Ok(resolve_path(base, &value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key_identity(value: String) -> Result<KeyIdentity, ConfigError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty()
|
||||||
|
|| trimmed.len() > 512
|
||||||
|
|| trimmed.chars().any(char::is_control)
|
||||||
|
|| trimmed != value
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "default_key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(KeyIdentity(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_editor(raw: RawEditor) -> Result<EditorCommand, ConfigError> {
|
||||||
|
let words = match raw {
|
||||||
|
RawEditor::CommandLine(command) => {
|
||||||
|
shlex::split(&command).ok_or(ConfigError::InvalidField { field: "editor" })?
|
||||||
|
}
|
||||||
|
RawEditor::Arguments(arguments) => arguments,
|
||||||
|
};
|
||||||
|
editor_from_words(words).map_err(|_| ConfigError::InvalidField { field: "editor" })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_from_words(mut words: Vec<String>) -> Result<EditorCommand, ()> {
|
||||||
|
if words.is_empty() || words.iter().any(|word| word.contains('\0')) {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let program = words.remove(0);
|
||||||
|
if program.is_empty() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(EditorCommand {
|
||||||
|
program,
|
||||||
|
arguments: words,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_environment_editor(
|
||||||
|
variable: &'static str,
|
||||||
|
value: Option<&std::ffi::OsStr>,
|
||||||
|
) -> Result<Option<EditorCommand>, EditorError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let value = value.to_str().ok_or(EditorError::NonUnicode { variable })?;
|
||||||
|
let words = shlex::split(value).ok_or(EditorError::InvalidCommand { source: variable })?;
|
||||||
|
editor_from_words(words)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|()| EditorError::InvalidCommand { source: variable })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
let mut references = BTreeSet::new();
|
||||||
|
let mut remotes = Vec::with_capacity(raw.len());
|
||||||
|
for remote in raw {
|
||||||
|
let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?);
|
||||||
|
let server_id = ServerId(validate_identifier(
|
||||||
|
"git.remotes.server_id",
|
||||||
|
remote.server_id,
|
||||||
|
)?);
|
||||||
|
let application_id = ApplicationId(validate_identifier(
|
||||||
|
"git.remotes.application_id",
|
||||||
|
remote.application_id,
|
||||||
|
)?);
|
||||||
|
if !names.insert(name.clone()) {
|
||||||
|
return Err(ConfigError::DuplicateRemote {
|
||||||
|
name: name.0.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !references.insert((server_id.clone(), application_id.clone())) {
|
||||||
|
return Err(ConfigError::DuplicateCredentialReference);
|
||||||
|
}
|
||||||
|
let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
||||||
|
name: name.0.clone(),
|
||||||
|
})?;
|
||||||
|
if url.scheme() != "https"
|
||||||
|
|| url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidRemoteUrl {
|
||||||
|
name: name.0.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remotes.push(GitRemote {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
server_id,
|
||||||
|
application_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(remotes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > 128
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidField { field });
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), ConfigError> {
|
||||||
|
let root = value.as_table().ok_or_else(|| ConfigError::UnknownField {
|
||||||
|
field: "<root>".to_owned(),
|
||||||
|
})?;
|
||||||
|
validate_table(
|
||||||
|
root,
|
||||||
|
"",
|
||||||
|
&["vault", "default_key", "key_material", "editor", "git"],
|
||||||
|
)?;
|
||||||
|
let Some(git) = root.get("git") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let git = git.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||||
|
path: source.to_owned(),
|
||||||
|
})?;
|
||||||
|
validate_table(git, "git", &["remotes"])?;
|
||||||
|
if let Some(remotes) = git.get("remotes") {
|
||||||
|
let remotes = remotes.as_array().ok_or_else(|| ConfigError::Malformed {
|
||||||
|
path: source.to_owned(),
|
||||||
|
})?;
|
||||||
|
for (index, remote) in remotes.iter().enumerate() {
|
||||||
|
let remote = remote.as_table().ok_or_else(|| ConfigError::Malformed {
|
||||||
|
path: source.to_owned(),
|
||||||
|
})?;
|
||||||
|
validate_table(
|
||||||
|
remote,
|
||||||
|
&format!("git.remotes[{index}]"),
|
||||||
|
&["name", "url", "server_id", "application_id"],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_table(table: &toml::Table, prefix: &str, allowed: &[&str]) -> Result<(), ConfigError> {
|
||||||
|
for key in table.keys() {
|
||||||
|
if !allowed.contains(&key.as_str()) {
|
||||||
|
let field = if prefix.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{key}")
|
||||||
|
};
|
||||||
|
return Err(ConfigError::UnknownField { field });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_insecure_fields(value: &toml::Value, prefix: &str) -> Result<(), ConfigError> {
|
||||||
|
match value {
|
||||||
|
toml::Value::Table(table) => {
|
||||||
|
for (key, nested) in table {
|
||||||
|
let path = if prefix.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{key}")
|
||||||
|
};
|
||||||
|
let normalized = key.to_ascii_lowercase().replace('-', "_");
|
||||||
|
if [
|
||||||
|
"password",
|
||||||
|
"passphrase",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"credential",
|
||||||
|
"credentials",
|
||||||
|
"private_key",
|
||||||
|
]
|
||||||
|
.contains(&normalized.as_str())
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InsecureField { field: path });
|
||||||
|
}
|
||||||
|
reject_insecure_fields(nested, &path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toml::Value::Array(values) => {
|
||||||
|
for (index, nested) in values.iter().enumerate() {
|
||||||
|
reject_insecure_fields(nested, &format!("{prefix}[{index}]"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
|
||||||
|
if path.is_absolute() {
|
||||||
|
normalize_absolute(path.to_owned())
|
||||||
|
} else {
|
||||||
|
normalize_absolute(base.join(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_absolute(path: PathBuf) -> PathBuf {
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::CurDir => {}
|
||||||
|
Component::ParentDir => {
|
||||||
|
if !normalized.pop() {
|
||||||
|
normalized.push(component.as_os_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => normalized.push(component.as_os_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
|
env::var_os("APPDATA").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
|
env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join("Library/Application Support"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
|
match env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
Some(path) if !path.is_empty() && Path::new(&path).is_absolute() => {
|
||||||
|
Some(PathBuf::from(path))
|
||||||
|
}
|
||||||
|
_ => env::var_os("HOME")
|
||||||
|
.filter(|home| !home.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join(".config")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, target_os = "windows")))]
|
||||||
|
fn native_config_directory() -> Option<PathBuf> {
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -5,5 +5,8 @@
|
|||||||
//!
|
//!
|
||||||
//! This crate is the sole owner of stored and derived password-store objects.
|
//! This crate is the sole owner of stored and derived password-store objects.
|
||||||
|
|
||||||
|
pub mod command;
|
||||||
|
pub mod config;
|
||||||
|
|
||||||
/// Product name shared by the presentation adapters.
|
/// Product name shared by the presentation adapters.
|
||||||
pub const PRODUCT_NAME: &str = "IronStorage";
|
pub const PRODUCT_NAME: &str = "IronStorage";
|
||||||
|
|||||||
328
crates/storage/tests/command_contract.rs
Normal file
328
crates/storage/tests/command_contract.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::{error::Error, num::NonZeroUsize, path::Path};
|
||||||
|
|
||||||
|
use ironstorage::command::{
|
||||||
|
CliAction, CommandRequest, EXIT_USAGE, GeneratedPresentation, GitConfigRequest,
|
||||||
|
GitRemoteRequest, GitRequest, HelpTopic, InputPlan, InsertInput, OtpInputSource, OtpRequest,
|
||||||
|
OtpUriPresentation, Presentation, help_text, otp_version_text, parse_from, version_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
|
fn request(arguments: &[&str]) -> Result<CommandRequest, Box<dyn Error>> {
|
||||||
|
let mut complete = vec!["ironstorage"];
|
||||||
|
complete.extend_from_slice(arguments);
|
||||||
|
match parse_from(complete)? {
|
||||||
|
CliAction::Run(invocation) => Ok(invocation.into_parts().1),
|
||||||
|
CliAction::Display(_) => Err("expected a command request".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn implicit_show_and_list_aliases_are_canonical_requests() -> TestResult {
|
||||||
|
assert!(matches!(
|
||||||
|
request(&[])?,
|
||||||
|
CommandRequest::Show(ref show)
|
||||||
|
if show.entry.is_none() && show.presentation == Presentation::Terminal
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
request(&["email/personal"] )?,
|
||||||
|
CommandRequest::Show(ref show)
|
||||||
|
if show.entry.as_deref() == Some("email/personal")
|
||||||
|
));
|
||||||
|
assert_eq!(request(&["ls", "team"])?, request(&["list", "team"])?);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["find", "service"])?,
|
||||||
|
request(&["search", "service"])?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mutation_aliases_map_to_the_same_storage_request() -> TestResult {
|
||||||
|
assert_eq!(
|
||||||
|
request(&["insert", "-f", "new/entry"])?,
|
||||||
|
request(&["add", "--force", "new/entry"])?
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["rm", "-rf", "team/"])?,
|
||||||
|
request(&["remove", "--recursive", "--force", "team/"])?
|
||||||
|
);
|
||||||
|
assert_eq!(request(&["rm", "entry"])?, request(&["delete", "entry"])?);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["mv", "old", "new"])?,
|
||||||
|
request(&["rename", "old", "new"])?
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["cp", "old", "new"])?,
|
||||||
|
request(&["copy", "old", "new"])?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_presentation_lines_are_typed_and_conflicts_fail() -> TestResult {
|
||||||
|
assert!(matches!(
|
||||||
|
request(&["show", "--clip=2", "entry"] )?,
|
||||||
|
CommandRequest::Show(ref show)
|
||||||
|
if show.presentation == Presentation::Clipboard {
|
||||||
|
line: NonZeroUsize::new(2).expect("nonzero")
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
request(&["show", "-q3", "entry"] )?,
|
||||||
|
CommandRequest::Show(ref show)
|
||||||
|
if show.presentation == Presentation::QrCode {
|
||||||
|
line: NonZeroUsize::new(3).expect("nonzero")
|
||||||
|
}
|
||||||
|
));
|
||||||
|
for invalid in [
|
||||||
|
vec!["show", "--clip", "--qrcode", "entry"],
|
||||||
|
vec!["show", "--clip=0", "entry"],
|
||||||
|
vec!["show", "--clip=not-a-number", "entry"],
|
||||||
|
] {
|
||||||
|
let error = parse_from(std::iter::once("ironstorage").chain(invalid))
|
||||||
|
.expect_err("invalid presentation");
|
||||||
|
assert_eq!(error.exit_code(), EXIT_USAGE);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_modes_conflicts_and_noninteractive_input_are_explicit() -> TestResult {
|
||||||
|
let hidden = request(&["insert", "entry"])?;
|
||||||
|
let CommandRequest::Insert(hidden) = hidden else {
|
||||||
|
return Err("expected insert".into());
|
||||||
|
};
|
||||||
|
assert_eq!(hidden.input, InsertInput::HiddenConfirmed);
|
||||||
|
assert_eq!(hidden.input_plan(true), InputPlan::HiddenConfirmed);
|
||||||
|
assert_eq!(hidden.input_plan(false), InputPlan::StandardInputLine);
|
||||||
|
|
||||||
|
let multiline = request(&["insert", "--multiline", "entry"])?;
|
||||||
|
let CommandRequest::Insert(multiline) = multiline else {
|
||||||
|
return Err("expected multiline insert".into());
|
||||||
|
};
|
||||||
|
assert_eq!(multiline.input, InsertInput::Multiline);
|
||||||
|
assert_eq!(multiline.input_plan(false), InputPlan::StandardInputToEnd);
|
||||||
|
|
||||||
|
let echo = request(&["insert", "--echo", "entry"])?;
|
||||||
|
let CommandRequest::Insert(echo) = echo else {
|
||||||
|
return Err("expected echo insert".into());
|
||||||
|
};
|
||||||
|
assert_eq!(echo.input_plan(true), InputPlan::EchoedLine);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_from(["ironstorage", "insert", "--echo", "--multiline", "entry"])
|
||||||
|
.expect_err("conflicting insert modes")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_options_defaults_and_conflicts_are_validated() -> TestResult {
|
||||||
|
let generated = request(&["generate", "--no-symbols", "--clip", "entry", "32"])?;
|
||||||
|
assert!(matches!(
|
||||||
|
generated,
|
||||||
|
CommandRequest::Generate(ref request)
|
||||||
|
if request.no_symbols
|
||||||
|
&& request.length == NonZeroUsize::new(32)
|
||||||
|
&& request.presentation == GeneratedPresentation::Clipboard
|
||||||
|
));
|
||||||
|
for invalid in [
|
||||||
|
["generate", "--clip", "--qrcode", "entry"].as_slice(),
|
||||||
|
["generate", "--force", "--in-place", "entry"].as_slice(),
|
||||||
|
["generate", "entry", "0"].as_slice(),
|
||||||
|
] {
|
||||||
|
let error = parse_from(std::iter::once("ironstorage").chain(invalid.iter().copied()))
|
||||||
|
.expect_err("invalid generate request");
|
||||||
|
assert_eq!(error.exit_code(), EXIT_USAGE);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grep_support_is_explicit_and_unknown_gnu_options_fail() -> TestResult {
|
||||||
|
assert!(matches!(
|
||||||
|
request(&["grep", "-ivnF", "fixture"] )?,
|
||||||
|
CommandRequest::Grep(ref grep)
|
||||||
|
if grep.ignore_case && grep.invert_match && grep.line_number && grep.fixed_strings
|
||||||
|
));
|
||||||
|
let error = parse_from(["ironstorage", "grep", "--binary-files=text", "fixture"])
|
||||||
|
.expect_err("unsupported grep option");
|
||||||
|
assert_eq!(error.exit_code(), EXIT_USAGE);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_workflows_are_typed_and_arbitrary_passthrough_is_rejected() -> TestResult {
|
||||||
|
assert_eq!(
|
||||||
|
request(&["git", "init"])?,
|
||||||
|
CommandRequest::Git(GitRequest::Init)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["git", "remote"])?,
|
||||||
|
CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::List))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&[
|
||||||
|
"git",
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
"https://example.test/store.git",
|
||||||
|
])?,
|
||||||
|
CommandRequest::Git(GitRequest::Remote(GitRemoteRequest::Add {
|
||||||
|
name: "origin".to_owned(),
|
||||||
|
url: "https://example.test/store.git".to_owned(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["git", "config", "--get", "remote.origin.url"])?,
|
||||||
|
CommandRequest::Git(GitRequest::Config(GitConfigRequest::Get {
|
||||||
|
key: "remote.origin.url".to_owned()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request(&[
|
||||||
|
"git",
|
||||||
|
"config",
|
||||||
|
"remote.origin.url",
|
||||||
|
"https://example.test/store.git",
|
||||||
|
])?,
|
||||||
|
CommandRequest::Git(GitRequest::Config(GitConfigRequest::Set {
|
||||||
|
key: "remote.origin.url".to_owned(),
|
||||||
|
value: "https://example.test/store.git".to_owned(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
for command in ["rebase", "cherry-pick", "credential"] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_from(["ironstorage", "git", command])
|
||||||
|
.expect_err("unsupported Git passthrough")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn otp_default_dispatch_aliases_and_input_contract_are_complete() -> TestResult {
|
||||||
|
let default = request(&["otp", "otp/totp"])?;
|
||||||
|
assert_eq!(default, request(&["otp", "code", "otp/totp"])?);
|
||||||
|
assert_eq!(default, request(&["otp", "show", "otp/totp"])?);
|
||||||
|
assert_eq!(
|
||||||
|
request(&["otp", "insert", "otp/new"])?,
|
||||||
|
request(&["otp", "add", "otp/new"])?
|
||||||
|
);
|
||||||
|
|
||||||
|
let inserted = request(&[
|
||||||
|
"otp",
|
||||||
|
"insert",
|
||||||
|
"--secret",
|
||||||
|
"--issuer",
|
||||||
|
"Issuer",
|
||||||
|
"--account",
|
||||||
|
"account",
|
||||||
|
])?;
|
||||||
|
let CommandRequest::Otp(OtpRequest::Insert(inserted)) = inserted else {
|
||||||
|
return Err("expected OTP insert".into());
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
inserted.source,
|
||||||
|
OtpInputSource::Secret {
|
||||||
|
issuer: Some("Issuer".to_owned()),
|
||||||
|
account: Some("account".to_owned())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(inserted.input_plan(true), InputPlan::HiddenConfirmed);
|
||||||
|
assert_eq!(inserted.input_plan(false), InputPlan::StandardInputLine);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parse_from(["ironstorage", "otp", "insert", "--secret"])
|
||||||
|
.expect_err("secret without identity")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_from(["ironstorage", "otp", "insert", "--issuer", "Issuer"])
|
||||||
|
.expect_err("issuer without secret")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn otp_uri_presentation_and_conflicts_are_typed() -> TestResult {
|
||||||
|
assert!(matches!(
|
||||||
|
request(&["otp", "uri", "--qrcode", "otp/totp"] )?,
|
||||||
|
CommandRequest::Otp(OtpRequest::Uri(ref uri))
|
||||||
|
if uri.presentation == OtpUriPresentation::QrCode
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
parse_from([
|
||||||
|
"ironstorage",
|
||||||
|
"otp",
|
||||||
|
"uri",
|
||||||
|
"--clip",
|
||||||
|
"--qrcode",
|
||||||
|
"otp/totp",
|
||||||
|
])
|
||||||
|
.expect_err("conflicting OTP URI presentation")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configuration_option_and_meta_commands_have_stable_contracts() -> TestResult {
|
||||||
|
let action = parse_from([
|
||||||
|
"ironstorage",
|
||||||
|
"--config",
|
||||||
|
"relative/config.toml",
|
||||||
|
"show",
|
||||||
|
"entry",
|
||||||
|
])?;
|
||||||
|
let CliAction::Run(invocation) = action else {
|
||||||
|
return Err("expected invocation".into());
|
||||||
|
};
|
||||||
|
assert_eq!(invocation.config(), Some(Path::new("relative/config.toml")));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request(&["help", "search"])?,
|
||||||
|
CommandRequest::Help {
|
||||||
|
topic: Some(HelpTopic::Find)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(request(&["version"])?, CommandRequest::Version);
|
||||||
|
assert!(!help_text(None).is_empty());
|
||||||
|
assert!(help_text(Some(HelpTopic::Otp)).contains("Usage"));
|
||||||
|
assert!(version_text().starts_with("IronStorage "));
|
||||||
|
assert_eq!(otp_version_text(), "1.1.1\n");
|
||||||
|
|
||||||
|
for option in ["--help", "--version"] {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_from(["ironstorage", option])?,
|
||||||
|
CliAction::Display(ref text) if !text.is_empty()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
parse_from(["ironstorage", "otp", "--help"] )?,
|
||||||
|
CliAction::Display(ref text) if text.contains("Usage")
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
request(&["otp", "--version"])?,
|
||||||
|
CommandRequest::Otp(OtpRequest::Version)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_from(["ironstorage", "help", "not-a-command"])
|
||||||
|
.expect_err("unknown help topic")
|
||||||
|
.exit_code(),
|
||||||
|
EXIT_USAGE
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
343
crates/storage/tests/config_contract.rs
Normal file
343
crates/storage/tests/config_contract.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::{error::Error, ffi::OsStr, fs, path::Path};
|
||||||
|
|
||||||
|
use ironstorage::config::{ConfigError, ConfigLoader, EditorSource};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error>>;
|
||||||
|
|
||||||
|
struct ConfigurationFixture {
|
||||||
|
temporary: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigurationFixture {
|
||||||
|
fn new() -> Result<Self, Box<dyn Error>> {
|
||||||
|
let temporary = tempfile::tempdir()?;
|
||||||
|
fs::create_dir_all(temporary.path().join("cwd/config/keys"))?;
|
||||||
|
fs::create_dir_all(temporary.path().join("native"))?;
|
||||||
|
Ok(Self { temporary })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loader(&self) -> ConfigLoader {
|
||||||
|
ConfigLoader::new(
|
||||||
|
self.temporary.path().join("cwd"),
|
||||||
|
self.temporary.path().join("native"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_explicit(&self, contents: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
fs::write(self.explicit_path(), contents)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explicit_path(&self) -> std::path::PathBuf {
|
||||||
|
self.temporary.path().join("cwd/config/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_contents(&self) -> &'static str {
|
||||||
|
r#"
|
||||||
|
vault = "../vault"
|
||||||
|
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||||
|
key_material = "keys"
|
||||||
|
editor = ["code", "--wait"]
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "https://git.example.test/alice/store.git"
|
||||||
|
server_id = "personal-git"
|
||||||
|
application_id = "ironstorage-cli"
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_relative_configuration_resolves_deterministically() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit(fixture.valid_contents())?;
|
||||||
|
let config = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))?;
|
||||||
|
|
||||||
|
assert_eq!(config.source(), fixture.explicit_path());
|
||||||
|
assert_eq!(config.vault(), fixture.temporary.path().join("cwd/vault"));
|
||||||
|
assert_eq!(
|
||||||
|
config.key_material(),
|
||||||
|
fs::canonicalize(fixture.temporary.path().join("cwd/config/keys"))?
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.default_key().as_str(),
|
||||||
|
"0123456789ABCDEF0123456789ABCDEF01234567"
|
||||||
|
);
|
||||||
|
assert_eq!(config.git_remotes().len(), 1);
|
||||||
|
let remote = &config.git_remotes()[0];
|
||||||
|
assert_eq!(remote.name().as_str(), "origin");
|
||||||
|
assert_eq!(
|
||||||
|
remote.url().as_str(),
|
||||||
|
"https://git.example.test/alice/store.git"
|
||||||
|
);
|
||||||
|
assert_eq!(remote.server_id().as_str(), "personal-git");
|
||||||
|
assert_eq!(remote.application_id().as_str(), "ironstorage-cli");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_default_path_is_used_without_an_explicit_path() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
let default = fixture.loader().default_path();
|
||||||
|
fs::create_dir_all(default.parent().expect("default parent"))?;
|
||||||
|
fs::create_dir_all(default.parent().expect("default parent").join("keys"))?;
|
||||||
|
fs::write(&default, fixture.valid_contents())?;
|
||||||
|
|
||||||
|
let config = fixture.loader().load(None)?;
|
||||||
|
assert_eq!(config.source(), default);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_precedence_and_argument_splitting_are_storage_owned() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit(fixture.valid_contents())?;
|
||||||
|
let config = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))?;
|
||||||
|
let editor = config.resolve_editor_from(
|
||||||
|
Some(OsStr::new("visual --ignored")),
|
||||||
|
Some(OsStr::new("editor --ignored")),
|
||||||
|
)?;
|
||||||
|
assert_eq!(editor.source(), EditorSource::Configuration);
|
||||||
|
assert_eq!(editor.command().program(), "code");
|
||||||
|
assert_eq!(editor.command().arguments(), ["--wait"]);
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice@example.test"
|
||||||
|
key_material = "keys"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let config = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))?;
|
||||||
|
let visual = config.resolve_editor_from(
|
||||||
|
Some(OsStr::new("code --wait 'two words'")),
|
||||||
|
Some(OsStr::new("nano")),
|
||||||
|
)?;
|
||||||
|
assert_eq!(visual.source(), EditorSource::VisualEnvironment);
|
||||||
|
assert_eq!(visual.command().program(), "code");
|
||||||
|
assert_eq!(visual.command().arguments(), ["--wait", "two words"]);
|
||||||
|
|
||||||
|
let editor = config.resolve_editor_from(None, Some(OsStr::new("nano -w")))?;
|
||||||
|
assert_eq!(editor.source(), EditorSource::EditorEnvironment);
|
||||||
|
assert_eq!(editor.command().program(), "nano");
|
||||||
|
assert_eq!(editor.command().arguments(), ["-w"]);
|
||||||
|
|
||||||
|
let fallback = config.resolve_editor_from(None, None)?;
|
||||||
|
assert_eq!(fallback.source(), EditorSource::Fallback);
|
||||||
|
assert_eq!(fallback.command().program(), "vim");
|
||||||
|
assert!(fallback.command().arguments().is_empty());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_unknown_and_insecure_configuration_are_redacted() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit("vault = [\"unterminated\"\npassword = \"do-not-repeat\"")?;
|
||||||
|
let error = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("malformed TOML");
|
||||||
|
assert!(matches!(error, ConfigError::Malformed { .. }));
|
||||||
|
assert!(!error.to_string().contains("do-not-repeat"));
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
colour = "blue"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let error = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("unknown field");
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ConfigError::UnknownField {
|
||||||
|
field: "colour".to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
token = "do-not-repeat"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let error = fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("secret field");
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ConfigError::InsecureField {
|
||||||
|
field: "token".to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!error.to_string().contains("do-not-repeat"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_and_invalid_required_fields_are_typed() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit("default_key = \"alice\"\nkey_material = \"keys\"\n")?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("missing vault"),
|
||||||
|
ConfigError::MissingField { field: "vault" }
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.write_explicit("vault = \"vault\"\ndefault_key = \" \"\nkey_material = \"keys\"\n")?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("empty identity"),
|
||||||
|
ConfigError::InvalidField {
|
||||||
|
field: "default_key"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
"vault = \"vault\"\ndefault_key = \"alice\"\nkey_material = \"missing\"\n",
|
||||||
|
)?;
|
||||||
|
assert!(matches!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("missing keys"),
|
||||||
|
ConfigError::KeyMaterialNotFound { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
fixture.temporary.path().join("cwd/config/not-a-vault"),
|
||||||
|
b"file",
|
||||||
|
)?;
|
||||||
|
fixture.write_explicit(
|
||||||
|
"vault = \"not-a-vault\"\ndefault_key = \"alice\"\nkey_material = \"keys\"\n",
|
||||||
|
)?;
|
||||||
|
assert!(matches!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("vault file"),
|
||||||
|
ConfigError::VaultIsNotDirectory { .. }
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
for url in [
|
||||||
|
"ssh://git@example.test/store.git",
|
||||||
|
"git://example.test/store.git",
|
||||||
|
"file:///tmp/store.git",
|
||||||
|
"../store.git",
|
||||||
|
"https://user:password@example.test/store.git",
|
||||||
|
"https://example.test/store.git?token=do-not-store",
|
||||||
|
"https://example.test/store.git#fragment",
|
||||||
|
] {
|
||||||
|
fixture.write_explicit(&format!(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "{url}"
|
||||||
|
server_id = "server"
|
||||||
|
application_id = "application"
|
||||||
|
"#
|
||||||
|
))?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("forbidden remote"),
|
||||||
|
ConfigError::InvalidRemoteUrl {
|
||||||
|
name: "origin".to_owned()
|
||||||
|
},
|
||||||
|
"URL should be rejected without entering transport: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "https://one.example.test/store.git"
|
||||||
|
server_id = "one"
|
||||||
|
application_id = "app"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "https://two.example.test/store.git"
|
||||||
|
server_id = "two"
|
||||||
|
application_id = "app"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("duplicate name"),
|
||||||
|
ConfigError::DuplicateRemote {
|
||||||
|
name: "origin".to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "one"
|
||||||
|
url = "https://one.example.test/store.git"
|
||||||
|
server_id = "server"
|
||||||
|
application_id = "app"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "two"
|
||||||
|
url = "https://two.example.test/store.git"
|
||||||
|
server_id = "server"
|
||||||
|
application_id = "app"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(Path::new("config/config.toml")))
|
||||||
|
.expect_err("duplicate credential reference"),
|
||||||
|
ConfigError::DuplicateCredentialReference
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
53
docs/configuration.md
Normal file
53
docs/configuration.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
IronStorage uses one small TOML file. Pass an explicit file with
|
||||||
|
`--config PATH`; otherwise the CLI loads `ironstorage/config.toml` below the
|
||||||
|
native per-user configuration directory:
|
||||||
|
|
||||||
|
- Linux and other Unix systems: `$XDG_CONFIG_HOME`, when it is absolute, or
|
||||||
|
`$HOME/.config`;
|
||||||
|
- macOS: `$HOME/Library/Application Support`;
|
||||||
|
- Windows: `%APPDATA%`.
|
||||||
|
|
||||||
|
An explicit relative configuration path is resolved from the process current
|
||||||
|
directory. Relative `vault` and `key_material` paths inside the file are
|
||||||
|
resolved from the directory containing the resolved configuration file.
|
||||||
|
Absolute paths remain absolute. `.` and `..` components are normalized
|
||||||
|
lexically; `~` is not expanded. The vault may be absent before `init`, but an
|
||||||
|
existing vault must be a directory. Exported key material must already exist
|
||||||
|
as a regular file or directory.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
vault = "../vault"
|
||||||
|
default_key = "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
# An array avoids shell interpretation. A quoted command string is also
|
||||||
|
# accepted and split without launching a shell.
|
||||||
|
editor = ["code", "--wait"]
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "https://git.example.test/alice/password-store.git"
|
||||||
|
server_id = "personal-git"
|
||||||
|
application_id = "ironstorage-cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
`default_key` is the required identity that the embedded OpenPGP layer resolves
|
||||||
|
against the exported key material. It may be a full fingerprint, key ID, or
|
||||||
|
exact user ID; ambiguity and key capability checks happen in the key resolver.
|
||||||
|
|
||||||
|
Editor selection is deterministic: TOML, `$VISUAL`, `$EDITOR`, then `vim`.
|
||||||
|
Commands are split into an executable and argument vector and are never passed
|
||||||
|
through a shell. Only the CLI `edit` adapter may eventually launch this
|
||||||
|
configured editor.
|
||||||
|
|
||||||
|
Git remotes are HTTPS-only. URLs containing user information, passwords,
|
||||||
|
queries, or fragments are rejected. `server_id` and `application_id` are opaque
|
||||||
|
references used to retrieve credentials from the operating-system secret
|
||||||
|
store; duplicate names and duplicate reference pairs are errors.
|
||||||
|
|
||||||
|
Passwords, passphrases, tokens, credentials, private keys, and other secret
|
||||||
|
values are forbidden in TOML. Unknown fields are rejected. Parse errors never
|
||||||
|
echo the source line or value, so an accidentally supplied secret is not
|
||||||
|
repeated in diagnostics.
|
||||||
Reference in New Issue
Block a user