mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
cosmic-conf: watch + theme asset installation
watch: source-include expansion by textual splicing so diagnostic spans stay correct across merged files; debounced inotify; a bad edit prints diagnostics and keeps watching rather than killing the daemon. assets: plan/apply split mirroring emit.rs. Tarball entries and symlink/ hardlink targets are validated before extraction, sharing one routine between plan and apply so the check cannot drift. Added tests/archive_escape.rs as independent verification of that boundary. The tar crate refuses to build hostile archives through its safe API, so the fixtures write GNU header name/linkname bytes directly — the same thing a malicious archiver does. Asserts on the filesystem afterwards rather than on returned errors, and covers symlink indirection, where neither entry path contains '..' yet a later write still escapes. 99 tests.
This commit is contained in:
@@ -5,3 +5,6 @@
|
|||||||
target/
|
target/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
# Fork; becomes a submodule per spec repo topology
|
||||||
|
cosmic-comp/
|
||||||
|
|||||||
Generated
+280
-3
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -21,11 +27,23 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
name = "cosmic-conf"
|
name = "cosmic-conf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"flate2",
|
||||||
|
"notify",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -33,7 +51,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -42,6 +60,35 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsevent-sys"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -53,6 +100,46 @@ dependencies = [
|
|||||||
"r-efi",
|
"r-efi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -65,6 +152,61 @@ 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 = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "8.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fsevent-sys",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio",
|
||||||
|
"notify-types",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-types"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -119,7 +261,16 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -152,6 +303,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
@@ -163,6 +320,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -173,7 +341,7 @@ dependencies = [
|
|||||||
"getrandom",
|
"getrandom",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -188,12 +356,46 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
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 = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -202,3 +404,78 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ license = "GPL-3.0-only"
|
|||||||
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
flate2 = "1.1.9"
|
||||||
|
notify = "8.2.0"
|
||||||
ron = "0.12"
|
ron = "0.12"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
tar = "0.4.46"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# `emit` links cosmic-config and the component crates. Off by default so the
|
# `emit` links cosmic-config and the component crates. Off by default so the
|
||||||
|
|||||||
@@ -0,0 +1,862 @@
|
|||||||
|
//! Install the non-config parts of a HyDE theme: GTK/icon themes, wallpapers,
|
||||||
|
//! and the upstream `.theme` files that belong to waybar/rofi/kitty.
|
||||||
|
//!
|
||||||
|
//! `import.rs` handles `hypr.theme` -> `cosmic.conf`. Everything else in a
|
||||||
|
//! HyDE theme directory is either a foreign binary blob (a tarball) or a
|
||||||
|
//! config file for a program cosmic-conf does not own, so there is nothing to
|
||||||
|
//! translate — only to place correctly. As with `emit.rs`, installation is
|
||||||
|
//! two-stage: `plan` discovers what would happen without touching disk,
|
||||||
|
//! `apply` does the writing. A theme directory is untrusted input (it may
|
||||||
|
//! have been downloaded from anywhere), so the tarball extraction path is
|
||||||
|
//! hardened against path traversal.
|
||||||
|
//!
|
||||||
|
//! Real layout, verified against `HyDE-Project/hyde-themes`, branch
|
||||||
|
//! `Catppuccin-Mocha`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Configs/.config/hyde/themes/<Theme Name>/hypr.theme
|
||||||
|
//! Configs/.config/hyde/themes/<Theme Name>/waybar.theme
|
||||||
|
//! Configs/.config/hyde/themes/<Theme Name>/rofi.theme
|
||||||
|
//! Configs/.config/hyde/themes/<Theme Name>/kitty.theme
|
||||||
|
//! Configs/.config/hyde/themes/<Theme Name>/wallpapers/*
|
||||||
|
//! Source/Gtk_<Name>.tar.gz
|
||||||
|
//! Source/Icon_<Name>.tar.gz
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The GTK/icon tarballs live in a `Source/` directory that is a sibling of
|
||||||
|
//! `Configs/`, not inside the per-theme folder, so `plan` takes it as a
|
||||||
|
//! separate optional argument rather than assuming it is nested under
|
||||||
|
//! `theme_dir`.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AssetError {
|
||||||
|
Io(io::Error),
|
||||||
|
/// A tarball entry (or, for a symlink/hardlink, its link target) with an
|
||||||
|
/// absolute path or a `..` component. Both would let extraction write
|
||||||
|
/// outside `dest_root`, so this is refused unconditionally rather than
|
||||||
|
/// sanitised — a theme directory is untrusted input.
|
||||||
|
UnsafeArchiveEntry { archive: PathBuf, entry: PathBuf },
|
||||||
|
NoHomeDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AssetError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
AssetError::Io(e) => write!(f, "io error: {e}"),
|
||||||
|
AssetError::UnsafeArchiveEntry { archive, entry } => write!(
|
||||||
|
f,
|
||||||
|
"refusing to extract `{}`: entry `{}` escapes the destination directory",
|
||||||
|
archive.display(),
|
||||||
|
entry.display()
|
||||||
|
),
|
||||||
|
AssetError::NoHomeDirectory => write!(f, "no home directory available"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AssetError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for AssetError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
AssetError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of asset a `Note` or `Action` concerns, for report labelling.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AssetKind {
|
||||||
|
Gtk,
|
||||||
|
Icon,
|
||||||
|
Wallpaper,
|
||||||
|
Waybar,
|
||||||
|
Rofi,
|
||||||
|
Kitty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetKind {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AssetKind::Gtk => "GTK theme",
|
||||||
|
AssetKind::Icon => "icon theme",
|
||||||
|
AssetKind::Wallpaper => "wallpaper",
|
||||||
|
AssetKind::Waybar => "waybar config",
|
||||||
|
AssetKind::Rofi => "rofi config",
|
||||||
|
AssetKind::Kitty => "kitty config",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an asset was left out of the plan.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SkipReason {
|
||||||
|
/// The destination already exists and `overwrite` was not requested.
|
||||||
|
/// Never overwriting silently is the point — a stale install must be
|
||||||
|
/// explicit, not a side effect of importing a new theme.
|
||||||
|
AlreadyInstalled,
|
||||||
|
/// The `.theme` file did not open with a `$HOME` destination header (see
|
||||||
|
/// `split_hyde_header`), so there is no known upstream location to copy
|
||||||
|
/// it to.
|
||||||
|
NoDestinationHeader,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkipReason {
|
||||||
|
pub fn describe(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SkipReason::AlreadyInstalled => "already installed; pass --overwrite to replace it",
|
||||||
|
SkipReason::NoDestinationHeader => {
|
||||||
|
"no $HOME destination header; cannot determine an install path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Note {
|
||||||
|
pub kind: AssetKind,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub reason: SkipReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pending install step.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Extract a GTK/icon tarball into `dest` (`~/.themes` or `~/.icons`).
|
||||||
|
/// The tarball supplies its own top-level directory name.
|
||||||
|
ExtractArchive {
|
||||||
|
kind: AssetKind,
|
||||||
|
archive: PathBuf,
|
||||||
|
dest: PathBuf,
|
||||||
|
},
|
||||||
|
/// A wallpaper image, copied byte-for-byte.
|
||||||
|
CopyWallpaper { src: PathBuf, dest: PathBuf },
|
||||||
|
/// A waybar/rofi/kitty `.theme` file, copied with its HyDE destination
|
||||||
|
/// header stripped (see `split_hyde_header`) but its body untouched.
|
||||||
|
CopyVerbatim {
|
||||||
|
kind: AssetKind,
|
||||||
|
src: PathBuf,
|
||||||
|
dest: PathBuf,
|
||||||
|
contents: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Plan {
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
pub skipped: Vec<Note>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Report {
|
||||||
|
pub installed: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable summary, in the same spirit as `import::render_report`:
|
||||||
|
/// nothing that was skipped is left unmentioned.
|
||||||
|
pub fn render_report(plan: &Plan, report: &Report) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
if report.installed.is_empty() {
|
||||||
|
out.push_str("Nothing was installed.\n");
|
||||||
|
} else {
|
||||||
|
out.push_str("Installed:\n");
|
||||||
|
for p in &report.installed {
|
||||||
|
out.push_str(&format!(" {}\n", p.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plan.skipped.is_empty() {
|
||||||
|
out.push_str("\nSkipped:\n");
|
||||||
|
for n in &plan.skipped {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} ({}): {}\n",
|
||||||
|
n.path.display(),
|
||||||
|
n.kind.label(),
|
||||||
|
n.reason.describe()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Installer {
|
||||||
|
/// `$XDG_DATA_HOME`, falling back to `$HOME/.local/share` — same
|
||||||
|
/// fallback shape as `Emitter::from_env` (emit.rs:126-137). Wallpapers
|
||||||
|
/// live under here.
|
||||||
|
data_home: PathBuf,
|
||||||
|
/// `$HOME` itself: GTK/icon themes go to the legacy `~/.themes` /
|
||||||
|
/// `~/.icons` locations rather than anywhere under `data_home`, and it is
|
||||||
|
/// also what a `.theme` file's `$HOME`-relative destination header
|
||||||
|
/// expands against.
|
||||||
|
home: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Installer {
|
||||||
|
pub fn from_env() -> Result<Self, AssetError> {
|
||||||
|
let home = std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or(AssetError::NoHomeDirectory)?;
|
||||||
|
let data_home = match std::env::var_os("XDG_DATA_HOME") {
|
||||||
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||||
|
_ => home.join(".local").join("share"),
|
||||||
|
};
|
||||||
|
Ok(Self { data_home, home })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_paths(data_home: impl Into<PathBuf>, home: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
data_home: data_home.into(),
|
||||||
|
home: home.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover every installable asset in a theme and decide where it would
|
||||||
|
/// go, without writing anything. Mirrors `Emitter::plan` / `plan_one`:
|
||||||
|
/// return every error rather than the first, and never touch the
|
||||||
|
/// destination tree.
|
||||||
|
///
|
||||||
|
/// `overwrite` controls what happens when a destination already exists —
|
||||||
|
/// see `SkipReason::AlreadyInstalled`. `theme_name` names the wallpaper
|
||||||
|
/// subdirectory (`$XDG_DATA_HOME/wallpapers/hyprcosmic/<theme_name>/`).
|
||||||
|
pub fn plan(
|
||||||
|
&self,
|
||||||
|
theme_dir: &Path,
|
||||||
|
source_dir: Option<&Path>,
|
||||||
|
theme_name: &str,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<Plan, Vec<AssetError>> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
let mut skipped = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
if let Some(source_dir) = source_dir {
|
||||||
|
for (kind, prefix, dest_root) in [
|
||||||
|
(AssetKind::Gtk, "Gtk_", self.home.join(".themes")),
|
||||||
|
(AssetKind::Icon, "Icon_", self.home.join(".icons")),
|
||||||
|
] {
|
||||||
|
match find_tarball(source_dir, prefix) {
|
||||||
|
Ok(Some(archive)) => {
|
||||||
|
match self.plan_archive(kind, &archive, &dest_root, overwrite) {
|
||||||
|
Ok(Some(action)) => actions.push(action),
|
||||||
|
Ok(None) => skipped.push(Note {
|
||||||
|
kind,
|
||||||
|
path: archive,
|
||||||
|
reason: SkipReason::AlreadyInstalled,
|
||||||
|
}),
|
||||||
|
Err(e) => errors.push(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {} // No tarball of this kind — not every theme ships both.
|
||||||
|
Err(e) => errors.push(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.plan_wallpapers(theme_dir, theme_name, overwrite, &mut actions, &mut skipped, &mut errors);
|
||||||
|
|
||||||
|
for (kind, filename) in [
|
||||||
|
(AssetKind::Waybar, "waybar.theme"),
|
||||||
|
(AssetKind::Rofi, "rofi.theme"),
|
||||||
|
(AssetKind::Kitty, "kitty.theme"),
|
||||||
|
] {
|
||||||
|
self.plan_verbatim(theme_dir, kind, filename, overwrite, &mut actions, &mut skipped, &mut errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(Plan { actions, skipped })
|
||||||
|
} else {
|
||||||
|
Err(errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_archive(
|
||||||
|
&self,
|
||||||
|
kind: AssetKind,
|
||||||
|
archive: &Path,
|
||||||
|
dest_root: &Path,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> Result<Option<Action>, AssetError> {
|
||||||
|
// `write: false` — this only reads the tarball's headers to validate
|
||||||
|
// and name-check it; extraction happens in `apply`.
|
||||||
|
let entries = walk_archive(archive, dest_root, false)?;
|
||||||
|
if !overwrite {
|
||||||
|
let already_present = top_level_names(&entries)
|
||||||
|
.into_iter()
|
||||||
|
.any(|name| dest_root.join(name).exists());
|
||||||
|
if already_present {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(Action::ExtractArchive {
|
||||||
|
kind,
|
||||||
|
archive: archive.to_path_buf(),
|
||||||
|
dest: dest_root.to_path_buf(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_wallpapers(
|
||||||
|
&self,
|
||||||
|
theme_dir: &Path,
|
||||||
|
theme_name: &str,
|
||||||
|
overwrite: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
skipped: &mut Vec<Note>,
|
||||||
|
errors: &mut Vec<AssetError>,
|
||||||
|
) {
|
||||||
|
let wallpapers_dir = theme_dir.join("wallpapers");
|
||||||
|
if !wallpapers_dir.is_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dest_dir = self
|
||||||
|
.data_home
|
||||||
|
.join("wallpapers")
|
||||||
|
.join("hyprcosmic")
|
||||||
|
.join(theme_name);
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(&wallpapers_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(e.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(e.into());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let src = entry.path();
|
||||||
|
if !src.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dest = dest_dir.join(entry.file_name());
|
||||||
|
if !overwrite && dest.exists() {
|
||||||
|
skipped.push(Note {
|
||||||
|
kind: AssetKind::Wallpaper,
|
||||||
|
path: src,
|
||||||
|
reason: SkipReason::AlreadyInstalled,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
actions.push(Action::CopyWallpaper { src, dest });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_verbatim(
|
||||||
|
&self,
|
||||||
|
theme_dir: &Path,
|
||||||
|
kind: AssetKind,
|
||||||
|
filename: &str,
|
||||||
|
overwrite: bool,
|
||||||
|
actions: &mut Vec<Action>,
|
||||||
|
skipped: &mut Vec<Note>,
|
||||||
|
errors: &mut Vec<AssetError>,
|
||||||
|
) {
|
||||||
|
let src = theme_dir.join(filename);
|
||||||
|
if !src.is_file() {
|
||||||
|
return; // Optional — not every theme carries all three.
|
||||||
|
}
|
||||||
|
let text = match fs::read_to_string(&src) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(e.into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match split_hyde_header(&text, &self.home) {
|
||||||
|
Some((dest, body)) => {
|
||||||
|
if !overwrite && dest.exists() {
|
||||||
|
skipped.push(Note {
|
||||||
|
kind,
|
||||||
|
path: src,
|
||||||
|
reason: SkipReason::AlreadyInstalled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actions.push(Action::CopyVerbatim {
|
||||||
|
kind,
|
||||||
|
src,
|
||||||
|
dest,
|
||||||
|
contents: body.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => skipped.push(Note {
|
||||||
|
kind,
|
||||||
|
path: src,
|
||||||
|
reason: SkipReason::NoDestinationHeader,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the plan. Callers should `plan` first so failures — in
|
||||||
|
/// particular an unsafe archive entry — surface before anything is
|
||||||
|
/// written, matching `Emitter::apply`'s contract.
|
||||||
|
pub fn apply(&self, plan: &Plan) -> Result<Report, AssetError> {
|
||||||
|
let mut installed = Vec::new();
|
||||||
|
for action in &plan.actions {
|
||||||
|
match action {
|
||||||
|
Action::ExtractArchive { archive, dest, .. } => {
|
||||||
|
walk_archive(archive, dest, true)?;
|
||||||
|
installed.push(dest.clone());
|
||||||
|
}
|
||||||
|
Action::CopyWallpaper { src, dest } => {
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::copy(src, dest)?;
|
||||||
|
installed.push(dest.clone());
|
||||||
|
}
|
||||||
|
Action::CopyVerbatim { dest, contents, .. } => {
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(dest, contents)?;
|
||||||
|
installed.push(dest.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Report { installed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every HyDE `.theme` file — not just `hypr.theme` — opens with a
|
||||||
|
/// destination line for HyDE's own installer. Verified against
|
||||||
|
/// `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha`:
|
||||||
|
///
|
||||||
|
/// - `waybar.theme`: `$HOME/.config/waybar/theme.css|${scrDir}/wbarconfgen.sh`
|
||||||
|
/// - `rofi.theme`: `$HOME/.config/rofi/theme.rasi` (no pipe at all)
|
||||||
|
/// - `kitty.theme`: `$HOME/.config/kitty/theme.conf|killall -SIGUSR1 kitty`
|
||||||
|
///
|
||||||
|
/// Only the path before any `|` is a destination; the remainder is a
|
||||||
|
/// post-install hook (a script path or a shell command) that this crate does
|
||||||
|
/// not execute — running a command sourced from an untrusted theme would be
|
||||||
|
/// a code-execution hole, not a config install.
|
||||||
|
///
|
||||||
|
/// The header line itself is HyDE installer metadata, not part of the file
|
||||||
|
/// the target program reads (a bare path as the first line of `theme.rasi`
|
||||||
|
/// is not valid rofi syntax), so it is stripped from the returned body —
|
||||||
|
/// same call `import.rs::strip_hyde_header` makes for `hypr.theme`, just
|
||||||
|
/// exposed here because the destination is also needed, not only the body.
|
||||||
|
fn split_hyde_header<'a>(src: &'a str, home: &Path) -> Option<(PathBuf, &'a str)> {
|
||||||
|
let mut lines = src.splitn(2, '\n');
|
||||||
|
let first = lines.next()?;
|
||||||
|
let path_part = first.split('|').next().unwrap_or(first).trim();
|
||||||
|
let rest = path_part.strip_prefix("$HOME")?;
|
||||||
|
let dest = home.join(rest.trim_start_matches('/'));
|
||||||
|
Some((dest, lines.next().unwrap_or("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_tarball(dir: &Path, prefix: &str) -> Result<Option<PathBuf>, AssetError> {
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if name.starts_with(prefix) && name.ends_with(".tar.gz") {
|
||||||
|
return Ok(Some(entry.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read (and, when `write` is true, extract) a `.tar.gz`, validating every
|
||||||
|
/// entry first. Shared between `plan` (`write: false`, a pure read used only
|
||||||
|
/// to name-check and reject unsafe archives early) and `apply`
|
||||||
|
/// (`write: true`), so the safety check cannot drift between the two paths.
|
||||||
|
fn walk_archive(archive_path: &Path, dest_root: &Path, write: bool) -> Result<Vec<PathBuf>, AssetError> {
|
||||||
|
let file = fs::File::open(archive_path)?;
|
||||||
|
let mut ar = tar::Archive::new(GzDecoder::new(file));
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for entry in ar.entries()? {
|
||||||
|
let mut entry = entry?;
|
||||||
|
let rel = entry.path()?.into_owned();
|
||||||
|
reject_unsafe_path(archive_path, &rel)?;
|
||||||
|
|
||||||
|
// A symlink/hardlink's own entry path can be safe while its target
|
||||||
|
// still points outside `dest_root`; a later entry written "through"
|
||||||
|
// that link would then land wherever the link points. Same rule,
|
||||||
|
// applied to the target.
|
||||||
|
if matches!(entry.header().entry_type(), tar::EntryType::Symlink | tar::EntryType::Link) {
|
||||||
|
if let Some(target) = entry.link_name()? {
|
||||||
|
reject_unsafe_path(archive_path, &target)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if write {
|
||||||
|
let dest = dest_root.join(&rel);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
entry.unpack(&dest)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse anything that could escape `dest_root` once joined onto it: an
|
||||||
|
/// absolute path replaces the join outright, and a `..` component walks back
|
||||||
|
/// out of it. This is the hard security boundary — a theme directory is
|
||||||
|
/// untrusted input.
|
||||||
|
fn reject_unsafe_path(archive: &Path, entry: &Path) -> Result<(), AssetError> {
|
||||||
|
let escapes = entry.is_absolute() || entry.components().any(|c| matches!(c, Component::ParentDir));
|
||||||
|
if escapes {
|
||||||
|
return Err(AssetError::UnsafeArchiveEntry {
|
||||||
|
archive: archive.to_path_buf(),
|
||||||
|
entry: entry.to_path_buf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first path component of every entry, skipping a leading `./` — used
|
||||||
|
/// only as a best-effort "is this archive already installed?" heuristic
|
||||||
|
/// (real GTK/icon tarballs unpack into a single named directory), not as a
|
||||||
|
/// security boundary.
|
||||||
|
fn top_level_names(entries: &[PathBuf]) -> BTreeSet<PathBuf> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
p.components()
|
||||||
|
.find(|c| !matches!(c, Component::CurDir))
|
||||||
|
.map(|c| PathBuf::from(c.as_os_str()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Build a `.tar.gz` fixture programmatically so tests do not depend on
|
||||||
|
/// binary blobs checked into the repo.
|
||||||
|
fn make_tarball(dir: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let file = fs::File::create(&path).unwrap();
|
||||||
|
let enc = GzEncoder::new(file, Compression::default());
|
||||||
|
let mut builder = tar::Builder::new(enc);
|
||||||
|
for (entry_path, data) in entries {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder.append_data(&mut header, entry_path, *data).unwrap();
|
||||||
|
}
|
||||||
|
builder.into_inner().unwrap().finish().unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `make_tarball`, but writes the entry path straight into the
|
||||||
|
/// header bytes instead of going through `tar::Header::set_path`, which
|
||||||
|
/// already refuses absolute paths and `..` *at write time* — exactly the
|
||||||
|
/// gap `assets.rs` itself must close, since a hostile tarball is not
|
||||||
|
/// necessarily built with this crate's own tooling.
|
||||||
|
fn make_malicious_tarball(dir: &Path, name: &str, entry_path: &str, data: &[u8]) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
let file = fs::File::create(&path).unwrap();
|
||||||
|
let enc = GzEncoder::new(file, Compression::default());
|
||||||
|
let mut builder = tar::Builder::new(enc);
|
||||||
|
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_entry_type(tar::EntryType::Regular);
|
||||||
|
let name_bytes = entry_path.as_bytes();
|
||||||
|
header.as_mut_bytes()[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
header.set_cksum();
|
||||||
|
builder.append(&mut header, data).unwrap();
|
||||||
|
|
||||||
|
builder.into_inner().unwrap().finish().unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(path: &Path, contents: &str) {
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(path, contents).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_traversal_tarball_is_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
// A GTK tarball whose entry escapes via `..` instead of staying under
|
||||||
|
// its own top-level directory.
|
||||||
|
make_malicious_tarball(&source_dir, "Gtk_Evil.tar.gz", "../evil.txt", b"pwned");
|
||||||
|
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let err = installer
|
||||||
|
.plan(&theme_dir, Some(&source_dir), "Evil", false)
|
||||||
|
.expect_err("a path-traversal entry must be rejected, not silently extracted");
|
||||||
|
assert!(
|
||||||
|
err.iter()
|
||||||
|
.any(|e| matches!(e, AssetError::UnsafeArchiveEntry { .. })),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing must have been written, in `~/.themes` or anywhere else.
|
||||||
|
assert!(!home.join(".themes").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_path_entry_is_also_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
make_malicious_tarball(&source_dir, "Icon_Evil.tar.gz", "/etc/passwd", b"pwned");
|
||||||
|
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
|
||||||
|
let err = installer
|
||||||
|
.plan(&theme_dir, Some(&source_dir), "Evil", false)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.iter()
|
||||||
|
.any(|e| matches!(e, AssetError::UnsafeArchiveEntry { .. })),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_does_not_touch_disk() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(&theme_dir.join("wallpapers/bg.png"), "not really a png");
|
||||||
|
write(&theme_dir.join("rofi.theme"), "$HOME/.config/rofi/theme.rasi\n* { main-bg: #000; }\n");
|
||||||
|
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"* {}")]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer
|
||||||
|
.plan(&theme_dir, Some(&source_dir), "Mocha", false)
|
||||||
|
.expect("a well-formed theme must plan cleanly");
|
||||||
|
|
||||||
|
assert!(!plan.actions.is_empty());
|
||||||
|
assert!(!home.exists(), "planning must not create anything under $HOME");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wallpapers_are_copied() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(&theme_dir.join("wallpapers/bg.png"), "fake wallpaper bytes");
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data_home = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data_home, &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
let report = installer.apply(&plan).unwrap();
|
||||||
|
|
||||||
|
let dest = data_home.join("wallpapers/hyprcosmic/Mocha/bg.png");
|
||||||
|
assert!(report.installed.contains(&dest));
|
||||||
|
assert_eq!(fs::read_to_string(&dest).unwrap(), "fake wallpaper bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verbatim_body_is_copied_unchanged() {
|
||||||
|
// Real rofi.theme header + body, verified against
|
||||||
|
// HyDE-Project/hyde-themes (Catppuccin-Mocha branch).
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
let body = "* {\n main-bg: #11111be6;\n main-fg: #cdd6f4ff;\n}\n";
|
||||||
|
write(&theme_dir.join("rofi.theme"), &format!("$HOME/.config/rofi/theme.rasi\n{body}"));
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
let report = installer.apply(&plan).unwrap();
|
||||||
|
|
||||||
|
let dest = home.join(".config/rofi/theme.rasi");
|
||||||
|
assert!(report.installed.contains(&dest));
|
||||||
|
// Byte-identical to the source *body* — only the HyDE installer
|
||||||
|
// header line (not valid rofi syntax) is removed.
|
||||||
|
assert_eq!(fs::read_to_string(&dest).unwrap(), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waybar_and_kitty_headers_with_pipes_still_resolve() {
|
||||||
|
// waybar.theme and kitty.theme carry a post-install hook after `|`
|
||||||
|
// that must be ignored, not executed.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(
|
||||||
|
&theme_dir.join("waybar.theme"),
|
||||||
|
"$HOME/.config/waybar/theme.css|${scrDir}/wbarconfgen.sh\n@define-color bar-bg #000;\n",
|
||||||
|
);
|
||||||
|
write(
|
||||||
|
&theme_dir.join("kitty.theme"),
|
||||||
|
"$HOME/.config/kitty/theme.conf|killall -SIGUSR1 kitty\n## name: Mocha\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
installer.apply(&plan).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(home.join(".config/waybar/theme.css")).unwrap(),
|
||||||
|
"@define-color bar-bg #000;\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(home.join(".config/kitty/theme.conf")).unwrap(),
|
||||||
|
"## name: Mocha\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gtk_and_icon_tarballs_extract_to_the_legacy_directories() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk-3.0/gtk.css", b"* {}")]);
|
||||||
|
make_tarball(&source_dir, "Icon_Tela.tar.gz", &[("Tela/index.theme", b"[Icon Theme]")]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", false).unwrap();
|
||||||
|
installer.apply(&plan).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(home.join(".themes/Mocha/gtk-3.0/gtk.css")).unwrap(),
|
||||||
|
"* {}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(home.join(".icons/Tela/index.theme")).unwrap(),
|
||||||
|
"[Icon Theme]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_theme_is_skipped_without_overwrite() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"new")]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
// Simulate a theme already installed under the name the tarball uses.
|
||||||
|
write(&home.join(".themes/Mocha/gtk.css"), "old");
|
||||||
|
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", false).unwrap();
|
||||||
|
|
||||||
|
assert!(plan.actions.is_empty(), "already-installed theme must not be re-planned");
|
||||||
|
assert_eq!(plan.skipped.len(), 1);
|
||||||
|
assert_eq!(plan.skipped[0].reason, SkipReason::AlreadyInstalled);
|
||||||
|
|
||||||
|
// Confirm the skip is honoured all the way through apply, and the
|
||||||
|
// existing file is left untouched.
|
||||||
|
installer.apply(&plan).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(home.join(".themes/Mocha/gtk.css")).unwrap(), "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overwrite_flag_forces_reinstall() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
make_tarball(&source_dir, "Gtk_Mocha.tar.gz", &[("Mocha/gtk.css", b"new")]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
write(&home.join(".themes/Mocha/gtk.css"), "old");
|
||||||
|
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, Some(&source_dir), "Mocha", true).unwrap();
|
||||||
|
assert_eq!(plan.actions.len(), 1);
|
||||||
|
|
||||||
|
installer.apply(&plan).unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(home.join(".themes/Mocha/gtk.css")).unwrap(), "new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_file_without_a_destination_header_is_reported_not_dropped() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(&theme_dir.join("rofi.theme"), "* { main-bg: #000; }\n");
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
|
||||||
|
assert!(plan.actions.is_empty());
|
||||||
|
assert_eq!(plan.skipped.len(), 1);
|
||||||
|
assert_eq!(plan.skipped[0].reason, SkipReason::NoDestinationHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_optional_files_are_not_errors() {
|
||||||
|
// A theme dir with only wallpapers and no waybar/rofi/kitty/tarballs
|
||||||
|
// at all must still plan cleanly.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(&theme_dir.join("wallpapers/bg.png"), "x");
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
assert_eq!(plan.actions.len(), 1);
|
||||||
|
assert!(plan.skipped.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_lists_installed_and_skipped() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("theme");
|
||||||
|
write(&theme_dir.join("wallpapers/bg.png"), "x");
|
||||||
|
write(&theme_dir.join("rofi.theme"), "not a header\n* {}\n");
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let installer = Installer::with_paths(home.join(".local/share"), &home);
|
||||||
|
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap();
|
||||||
|
let report = installer.apply(&plan).unwrap();
|
||||||
|
|
||||||
|
let text = render_report(&plan, &report);
|
||||||
|
assert!(text.contains("Installed:"), "{text}");
|
||||||
|
assert!(text.contains("bg.png"), "{text}");
|
||||||
|
assert!(text.contains("Skipped:"), "{text}");
|
||||||
|
assert!(text.contains("no $HOME destination header"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_honours_xdg_data_home() {
|
||||||
|
// Same fallback chain as `Emitter::from_env` (emit.rs), applied to
|
||||||
|
// XDG_DATA_HOME instead of XDG_CONFIG_HOME.
|
||||||
|
let prev = std::env::var_os("XDG_DATA_HOME");
|
||||||
|
std::env::set_var("XDG_DATA_HOME", "/tmp/xdg-data-probe");
|
||||||
|
let installer = Installer::from_env().unwrap();
|
||||||
|
assert_eq!(installer.data_home, Path::new("/tmp/xdg-data-probe"));
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("XDG_DATA_HOME", v),
|
||||||
|
None => std::env::remove_var("XDG_DATA_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@
|
|||||||
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
||||||
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
pub mod emit;
|
pub mod emit;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod watch;
|
||||||
|
|
||||||
pub use emit::{EmitError, Emitter, Planned};
|
pub use emit::{EmitError, Emitter, Planned};
|
||||||
pub use import::{import_hypr_theme, render_report, Import};
|
pub use import::{import_hypr_theme, render_report, Import};
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
//! Filesystem watch: re-apply `cosmic.conf` on every edit.
|
||||||
|
//!
|
||||||
|
//! Two problems make this more than "call `notify` and re-run `main`'s
|
||||||
|
//! pipeline":
|
||||||
|
//!
|
||||||
|
//! 1. **`source` fans out the watch set.** `parser::Item::Source` lets a
|
||||||
|
//! config pull in other files (`parser.rs:66`), but `resolve` treats
|
||||||
|
//! `Source` as inert (`resolve.rs:87`) — nothing upstream actually expands
|
||||||
|
//! it yet, even though `resolve.rs:66` already assumes an "include
|
||||||
|
//! expansion" pass ran first. This module is that pass: `merge_text`
|
||||||
|
//! textually splices a sourced file's contents in place of its `source`
|
||||||
|
//! line, the same way Hyprland treats `source` as literal inclusion. Doing
|
||||||
|
//! it as text rather than AST-splicing means the merged string is one
|
||||||
|
//! coherent document, so `Span`s (which are just line/col, with no file
|
||||||
|
//! identity — `parser.rs:24`) stay correct for `render_diagnostic`
|
||||||
|
//! regardless of which physical file a line came from. It also means the
|
||||||
|
//! watch set has to be recomputed after every successful compile, since
|
||||||
|
//! editing a `source` line can add or remove files from it.
|
||||||
|
//!
|
||||||
|
//! 2. **A bad edit must not kill the daemon or half-apply.** `Emitter::plan`
|
||||||
|
//! already keeps `apply` transactional (`emit.rs:11-14`); this module's
|
||||||
|
//! job is to keep that guarantee across an unbounded stream of edits by
|
||||||
|
//! treating every compile failure as "log it and keep watching" rather
|
||||||
|
//! than propagating it out of the loop.
|
||||||
|
//!
|
||||||
|
//! The event loop itself (`watch`) is intentionally thin. Everything with
|
||||||
|
//! interesting logic — merging sources, debouncing — is a free function
|
||||||
|
//! usable without a real inotify watcher, per the module's tests.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
use crate::emit::{EmitError, Emitter, Planned};
|
||||||
|
use crate::parser::{self, Item, ParseError};
|
||||||
|
use crate::render_diagnostic;
|
||||||
|
use crate::resolve::{self, Diagnostic};
|
||||||
|
|
||||||
|
/// Editors commonly write a save as several syscalls (truncate, write,
|
||||||
|
/// rename); this is long enough to collapse those into one recompile without
|
||||||
|
/// making a real edit feel laggy.
|
||||||
|
const DEBOUNCE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// Everything that can go wrong compiling `config` (and whatever it sources)
|
||||||
|
/// into a plan. Every variant renders a complete, human-readable report —
|
||||||
|
/// `watch` just prints `Display` and moves on.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CompileError {
|
||||||
|
/// `config`, or something it `source`s, could not be read.
|
||||||
|
Read { path: PathBuf, error: io::Error },
|
||||||
|
/// A `source` chain refers back to a file already being expanded.
|
||||||
|
/// Splicing it would recurse forever, so this is reported instead.
|
||||||
|
Cycle { path: PathBuf },
|
||||||
|
/// A single file failed to parse on its own, before merging — `source`
|
||||||
|
/// and `error` are both that file's, so the line number is exact.
|
||||||
|
Parse {
|
||||||
|
path: PathBuf,
|
||||||
|
source: String,
|
||||||
|
error: ParseError,
|
||||||
|
},
|
||||||
|
/// The merged document failed to resolve. `source` is the full merged
|
||||||
|
/// text, so `diagnostics`' spans point at the right physical line no
|
||||||
|
/// matter which file contributed it.
|
||||||
|
Resolve {
|
||||||
|
source: String,
|
||||||
|
diagnostics: Vec<Diagnostic>,
|
||||||
|
},
|
||||||
|
/// Resolved cleanly but could not be turned into file contents.
|
||||||
|
Emit(Vec<EmitError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CompileError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CompileError::Read { path, error } => {
|
||||||
|
write!(f, "error: cannot read {}: {error}\n", path.display())
|
||||||
|
}
|
||||||
|
CompileError::Cycle { path } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"error: `source` cycle detected while expanding {}\n",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompileError::Parse { path, source, error } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"in {}:\n{}",
|
||||||
|
path.display(),
|
||||||
|
render_diagnostic(source, error.span, &error.message, None)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompileError::Resolve { source, diagnostics } => {
|
||||||
|
let mut out = String::new();
|
||||||
|
for d in diagnostics {
|
||||||
|
out.push_str(&render_diagnostic(source, d.span, &d.message, d.help.as_deref()));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"error: {} problem(s) found; nothing was written\n",
|
||||||
|
diagnostics.len()
|
||||||
|
));
|
||||||
|
write!(f, "{out}")
|
||||||
|
}
|
||||||
|
CompileError::Emit(errs) => {
|
||||||
|
let mut out = String::new();
|
||||||
|
for e in errs {
|
||||||
|
out.push_str(&format!("error: {e}\n"));
|
||||||
|
}
|
||||||
|
out.push_str("error: nothing was written\n");
|
||||||
|
write!(f, "{out}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CompileError {}
|
||||||
|
|
||||||
|
/// A completed compile: what to write, and what to watch.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Compiled {
|
||||||
|
pub planned: Vec<Planned>,
|
||||||
|
/// Every file that contributed content, `config` first. This is exactly
|
||||||
|
/// the set `watch` needs to be subscribed to for the *next* edit to be
|
||||||
|
/// noticed, and it can change from one compile to the next as `source`
|
||||||
|
/// lines are added, removed, or edited.
|
||||||
|
pub sources: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `config` — following `source` directives — resolve, and plan writes
|
||||||
|
/// against `emitter`, without touching disk.
|
||||||
|
///
|
||||||
|
/// Pulled out of `watch` so the compile pipeline is unit-testable without a
|
||||||
|
/// filesystem watcher: every test in this module drives `compile` directly.
|
||||||
|
pub fn compile(config: &Path, emitter: &Emitter) -> Result<Compiled, CompileError> {
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
let merged = merge_text(config, &mut ancestors, &mut sources)?;
|
||||||
|
|
||||||
|
let ast = parser::parse(&merged).map_err(|error| CompileError::Parse {
|
||||||
|
path: config.to_path_buf(),
|
||||||
|
source: merged.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let resolved = resolve::resolve(&ast).map_err(|diagnostics| CompileError::Resolve {
|
||||||
|
source: merged,
|
||||||
|
diagnostics,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let planned = emitter.plan(&resolved).map_err(CompileError::Emit)?;
|
||||||
|
|
||||||
|
Ok(Compiled { planned, sources })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `path`, then replace every `source = <path>` line with the
|
||||||
|
/// (recursively expanded) text of the sourced file, so the result is one
|
||||||
|
/// document `parser::parse` can consume in a single pass — see the module
|
||||||
|
/// doc for why textual splicing rather than AST splicing.
|
||||||
|
///
|
||||||
|
/// `ancestors` is the current inclusion chain (for cycle detection);
|
||||||
|
/// `watched` accumulates every file visited, in the order first seen.
|
||||||
|
fn merge_text(
|
||||||
|
path: &Path,
|
||||||
|
ancestors: &mut Vec<PathBuf>,
|
||||||
|
watched: &mut Vec<PathBuf>,
|
||||||
|
) -> Result<String, CompileError> {
|
||||||
|
let key = path.to_path_buf();
|
||||||
|
if ancestors.contains(&key) {
|
||||||
|
return Err(CompileError::Cycle { path: key });
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(path).map_err(|error| CompileError::Read {
|
||||||
|
path: key.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
watched.push(key.clone());
|
||||||
|
|
||||||
|
// Parsing here (rather than scanning text for `source =` ourselves) means
|
||||||
|
// we inherit the grammar's exact rules for comments and whitespace, so
|
||||||
|
// the line we splice at is always the one the real parser would call a
|
||||||
|
// `Source` item.
|
||||||
|
let ast = parser::parse(&raw).map_err(|error| CompileError::Parse {
|
||||||
|
path: key.clone(),
|
||||||
|
source: raw.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
collect_sources(&ast.items, &mut targets);
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Ok(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splicing changes line counts, so process bottom-up: replacing a later
|
||||||
|
// line first leaves every earlier line number still valid.
|
||||||
|
targets.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
|
||||||
|
ancestors.push(key);
|
||||||
|
let mut lines: Vec<String> = raw.lines().map(String::from).collect();
|
||||||
|
for (line_no, raw_path) in targets {
|
||||||
|
let target_path = resolve_source_path(path, &raw_path);
|
||||||
|
let included = merge_text(&target_path, ancestors, watched)?;
|
||||||
|
lines.splice(line_no - 1..line_no, included.lines().map(String::from));
|
||||||
|
}
|
||||||
|
ancestors.pop();
|
||||||
|
|
||||||
|
let mut out = lines.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-first walk collecting every `source` item's `(line, raw path)`.
|
||||||
|
/// Sections are recursed into: a `source` nested inside `general { .. }`
|
||||||
|
/// splices its contents into that section, matching Hyprland's textual
|
||||||
|
/// `source` semantics rather than only supporting top-level includes.
|
||||||
|
fn collect_sources(items: &[Item], out: &mut Vec<(usize, String)>) {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Item::Source { path } => out.push((path.span.line, path.value.clone())),
|
||||||
|
Item::Section { items, .. } => collect_sources(items, out),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `source` value the way a shell prompt would: `~/` against
|
||||||
|
/// `$HOME`, everything else relative to the directory of the file doing the
|
||||||
|
/// sourcing (not the process's cwd), so a config tree keeps working wherever
|
||||||
|
/// it is checked out.
|
||||||
|
fn resolve_source_path(containing_file: &Path, raw: &str) -> PathBuf {
|
||||||
|
let expanded = if raw == "~" {
|
||||||
|
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(raw))
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("~/") {
|
||||||
|
match std::env::var_os("HOME") {
|
||||||
|
Some(home) => PathBuf::from(home).join(rest),
|
||||||
|
None => PathBuf::from(raw),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
};
|
||||||
|
|
||||||
|
if expanded.is_absolute() {
|
||||||
|
expanded
|
||||||
|
} else {
|
||||||
|
containing_file
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block for the first item on `rx`, then keep draining anything that
|
||||||
|
/// arrives within `window` of the previous one. Returns `None` once the
|
||||||
|
/// sender side has been dropped and nothing more will ever come.
|
||||||
|
///
|
||||||
|
/// This is the whole debounce policy, factored out of `watch`'s loop so it
|
||||||
|
/// can be tested against a plain channel instead of real filesystem events —
|
||||||
|
/// editors write a save as several syscalls, and without this a single save
|
||||||
|
/// would trigger several redundant recompiles.
|
||||||
|
fn collect_batch<T>(rx: &mpsc::Receiver<T>, window: Duration) -> Option<Vec<T>> {
|
||||||
|
let first = rx.recv().ok()?;
|
||||||
|
let mut batch = vec![first];
|
||||||
|
while let Ok(next) = rx.recv_timeout(window) {
|
||||||
|
batch.push(next);
|
||||||
|
}
|
||||||
|
Some(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring `watcher`'s subscriptions in line with `wanted`, diffing against
|
||||||
|
/// `current` so files that stopped being sourced are actually unwatched
|
||||||
|
/// (otherwise the watch set only ever grows).
|
||||||
|
///
|
||||||
|
/// Best-effort: a `watch`/`unwatch` failure (e.g. a sourced file that does
|
||||||
|
/// not exist yet) is not fatal — the next successful compile will retry with
|
||||||
|
/// whatever the config asks for at that point.
|
||||||
|
fn sync_watches(watcher: &mut RecommendedWatcher, current: &mut HashSet<PathBuf>, wanted: &[PathBuf]) {
|
||||||
|
let wanted: HashSet<PathBuf> = wanted.iter().cloned().collect();
|
||||||
|
|
||||||
|
for stale in current.difference(&wanted) {
|
||||||
|
let _ = watcher.unwatch(stale);
|
||||||
|
}
|
||||||
|
for fresh in wanted.difference(current) {
|
||||||
|
let _ = watcher.watch(fresh, RecursiveMode::NonRecursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
*current = wanted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything that stops the daemon outright. Deliberately small: a broken
|
||||||
|
/// `cosmic.conf` is *not* one of these — see the module doc — so this is
|
||||||
|
/// only the notify plumbing itself failing to start.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WatchError {
|
||||||
|
Notify(notify::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WatchError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
WatchError::Notify(e) => write!(f, "watch error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WatchError {}
|
||||||
|
|
||||||
|
impl From<notify::Error> for WatchError {
|
||||||
|
fn from(e: notify::Error) -> Self {
|
||||||
|
WatchError::Notify(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch `config` — and everything it currently `source`s — reapplying on
|
||||||
|
/// every change until the watcher itself fails to start or stops delivering
|
||||||
|
/// events. A malformed edit is reported to stderr and waited past: see the
|
||||||
|
/// module doc for why that, not propagating the error, is the contract here.
|
||||||
|
pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
|
||||||
|
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
|
||||||
|
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
|
||||||
|
let mut watched: HashSet<PathBuf> = HashSet::new();
|
||||||
|
|
||||||
|
// Compile once up front: the desktop should reflect the config the
|
||||||
|
// moment the daemon starts, and this also tells us the initial watch
|
||||||
|
// set. If it fails, fall back to watching just `config` — that is the
|
||||||
|
// one file guaranteed to exist, and a later successful compile will
|
||||||
|
// widen the watch set to whatever it actually sources.
|
||||||
|
match compile(config, emitter) {
|
||||||
|
Ok(compiled) => {
|
||||||
|
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||||
|
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||||
|
}
|
||||||
|
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{e}");
|
||||||
|
sync_watches(&mut watcher, &mut watched, std::slice::from_ref(&config.to_path_buf()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(batch) = collect_batch(&rx, DEBOUNCE) else {
|
||||||
|
// The sender was dropped, which only happens if `watcher` itself
|
||||||
|
// was torn down — nothing more will ever arrive.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in &batch {
|
||||||
|
if let Err(e) = event {
|
||||||
|
eprintln!("watch error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match compile(config, emitter) {
|
||||||
|
Ok(compiled) => {
|
||||||
|
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||||
|
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||||
|
}
|
||||||
|
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Leave `watched` alone: the fix for a bad edit might land in
|
||||||
|
// an already-sourced file, and dropping back to watching
|
||||||
|
// only `config` would miss that.
|
||||||
|
eprintln!("{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(&path, contents).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compile ----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_with_no_source_directives_watches_just_the_config() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "general {\n autotile = true\n}\n");
|
||||||
|
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(compiled.sources, vec![config]);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_follows_a_source_directive_and_lists_it_as_a_watch_target() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let included = write(conf_dir.path(), "extra.conf", "general {\n autotile = true\n}\n");
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = extra.conf\n");
|
||||||
|
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(compiled.sources, vec![config, included]);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_expands_a_source_nested_inside_a_section() {
|
||||||
|
// The sourced file's contents become part of the enclosing section,
|
||||||
|
// the same way Hyprland's `source` is a literal text substitution.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "gaps.conf", "gaps_in = 5\ngaps_out = 10\n");
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n source = gaps.conf\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let planned = compile(&config, &Emitter::with_root(root_dir.path()))
|
||||||
|
.unwrap()
|
||||||
|
.planned;
|
||||||
|
let gaps = planned.iter().find(|p| p.path.ends_with("gaps")).expect("gaps planned");
|
||||||
|
assert_eq!(gaps.contents, "(10, 5)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_resolves_relative_sources_against_the_including_files_directory() {
|
||||||
|
// The including file lives in a subdirectory; `nested.conf` must be
|
||||||
|
// found relative to it, not relative to the process's cwd.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let sub = conf_dir.path().join("sub");
|
||||||
|
fs::create_dir_all(&sub).unwrap();
|
||||||
|
write(&sub, "nested.conf", "general {\n autotile = true\n}\n");
|
||||||
|
let config = write(&sub, "cosmic.conf", "source = nested.conf\n");
|
||||||
|
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
assert_eq!(compiled.sources.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_expands_tilde_against_home() {
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
write(home.path(), "shared.conf", "general {\n autotile = true\n}\n");
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = ~/shared.conf\n");
|
||||||
|
|
||||||
|
let prev = std::env::var_os("HOME");
|
||||||
|
std::env::set_var("HOME", home.path());
|
||||||
|
let result = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path()));
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("HOME", v),
|
||||||
|
None => std::env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let compiled = result.unwrap();
|
||||||
|
assert!(compiled.sources.contains(&home.path().join("shared.conf")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_follows_a_chain_of_nested_sources() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "c.conf", "autotile = true\n");
|
||||||
|
write(conf_dir.path(), "b.conf", "general {\n source = c.conf\n}\n");
|
||||||
|
let config = write(conf_dir.path(), "a.conf", "source = b.conf\n");
|
||||||
|
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap();
|
||||||
|
assert_eq!(compiled.sources.len(), 3);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_detects_a_source_cycle() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let a = conf_dir.path().join("a.conf");
|
||||||
|
let b = conf_dir.path().join("b.conf");
|
||||||
|
fs::write(&a, "source = b.conf\n").unwrap();
|
||||||
|
fs::write(&b, "source = a.conf\n").unwrap();
|
||||||
|
|
||||||
|
let err = compile(&a, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Cycle { .. }), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_reports_a_missing_source_file_without_panicking() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = does-not-exist.conf\n");
|
||||||
|
|
||||||
|
let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Read { .. }), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_surfaces_a_syntax_error_in_a_sourced_file() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "broken.conf", "this is not valid\n");
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = broken.conf\n");
|
||||||
|
|
||||||
|
let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
match err {
|
||||||
|
CompileError::Parse { path, .. } => assert_eq!(path, conf_dir.path().join("broken.conf")),
|
||||||
|
other => panic!("expected Parse, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_diagnostic_line_number_points_at_the_merged_document_not_the_fragment() {
|
||||||
|
// The offending line is line 1 of `bad.conf`, but after splicing it
|
||||||
|
// sits at line 2 of the merged document — the diagnostic must report
|
||||||
|
// the merged position so the caret lands on the right physical line.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "bad.conf", "gaps_inn = 8\n");
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n source = bad.conf\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
match err {
|
||||||
|
CompileError::Resolve { diagnostics, .. } => {
|
||||||
|
assert_eq!(diagnostics[0].span.line, 2);
|
||||||
|
}
|
||||||
|
other => panic!("expected Resolve, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_surfaces_resolve_diagnostics_for_an_unknown_key() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "general {\n gaps_inn = 8\n}\n");
|
||||||
|
|
||||||
|
let err = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Resolve { .. }), "{err}");
|
||||||
|
assert!(err.to_string().contains("unknown key"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `emit.rs`'s `plan_does_not_write`: `compile` only plans, so it
|
||||||
|
/// must leave the cosmic-config tree untouched.
|
||||||
|
#[test]
|
||||||
|
fn compile_does_not_write_to_the_config_root() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "general {\n autotile = true\n}\n");
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let _ = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(root_dir.path()).unwrap().next().is_none(),
|
||||||
|
"compile must leave the tree untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolve_source_path -----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_source_path_is_relative_to_the_including_file_not_the_cwd() {
|
||||||
|
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_source_path(including, "extra.conf"),
|
||||||
|
Path::new("/somewhere/deep/extra.conf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_source_path_leaves_absolute_paths_alone() {
|
||||||
|
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_source_path(including, "/etc/other.conf"),
|
||||||
|
Path::new("/etc/other.conf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tilde expansion against `$HOME` is covered end-to-end by
|
||||||
|
// `compile_expands_tilde_against_home` below rather than here too:
|
||||||
|
// `std::env::set_var` mutates process-global state, and the default
|
||||||
|
// test runner is multi-threaded, so two tests racing to set `HOME`
|
||||||
|
// would be a real source of flakiness rather than a hypothetical one.
|
||||||
|
|
||||||
|
// ---- collect_batch (debounce) -------------------------------------
|
||||||
|
//
|
||||||
|
// These exercise the debounce policy directly against a plain channel,
|
||||||
|
// with no filesystem or notify involvement at all, so they are fast and
|
||||||
|
// cannot flake on OS-level event timing.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_drains_everything_already_sent_before_it_was_called() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
for i in 0..5 {
|
||||||
|
tx.send(i).unwrap();
|
||||||
|
}
|
||||||
|
let batch = collect_batch(&rx, Duration::from_millis(30)).unwrap();
|
||||||
|
assert_eq!(batch, vec![0, 1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_returns_none_once_the_sender_is_dropped() {
|
||||||
|
let (tx, rx) = mpsc::channel::<i32>();
|
||||||
|
drop(tx);
|
||||||
|
assert!(collect_batch(&rx, Duration::from_millis(30)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_starts_a_fresh_batch_after_the_quiet_window_elapses() {
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let window = Duration::from_millis(20);
|
||||||
|
|
||||||
|
tx.send(1).unwrap();
|
||||||
|
let first = collect_batch(&rx, window).unwrap();
|
||||||
|
assert_eq!(first, vec![1]);
|
||||||
|
|
||||||
|
// Send the second burst from another thread after the window has
|
||||||
|
// safely elapsed (10x margin), so the main thread's blocking `recv`
|
||||||
|
// in the next `collect_batch` call has something to wake it up.
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(window * 10);
|
||||||
|
tx.send(2).unwrap();
|
||||||
|
});
|
||||||
|
let second = collect_batch(&rx, window).unwrap();
|
||||||
|
assert_eq!(second, vec![2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sync_watches ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// Exercises the real notify watch/unwatch bookkeeping — but only ever
|
||||||
|
// registers watches on files that already exist; no event is triggered
|
||||||
|
// or waited for, so this cannot flake on inotify timing.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_watches_adds_and_then_removes_a_watch() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let a = write(dir.path(), "a.conf", "");
|
||||||
|
let b = write(dir.path(), "b.conf", "");
|
||||||
|
|
||||||
|
let (tx, _rx) = mpsc::channel::<notify::Result<Event>>();
|
||||||
|
let mut watcher = notify::recommended_watcher(tx).unwrap();
|
||||||
|
let mut current = HashSet::new();
|
||||||
|
|
||||||
|
sync_watches(&mut watcher, &mut current, &[a.clone(), b.clone()]);
|
||||||
|
assert_eq!(current, HashSet::from([a.clone(), b.clone()]));
|
||||||
|
|
||||||
|
// Dropping `b` from the wanted set must unwatch it, not just stop
|
||||||
|
// tracking it, or the watch set would only ever grow.
|
||||||
|
sync_watches(&mut watcher, &mut current, &[a.clone()]);
|
||||||
|
assert_eq!(current, HashSet::from([a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Adversarial checks on theme-archive extraction.
|
||||||
|
//!
|
||||||
|
//! These are deliberately independent of `assets.rs`'s own unit tests. Theme
|
||||||
|
//! tarballs are downloaded from third-party repositories and extracted into the
|
||||||
|
//! user's home directory, so "a test named `path_traversal_is_rejected` passes"
|
||||||
|
//! is not sufficient evidence — these assert on the *filesystem* afterwards,
|
||||||
|
//! proving nothing escaped rather than trusting a returned error.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use cosmic_conf::assets::Installer;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Build a `.tar.gz` containing arbitrary entries, including hostile ones a
|
||||||
|
/// well-behaved archiver would refuse to produce.
|
||||||
|
fn hostile_tarball(path: &Path, entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) {
|
||||||
|
let file = fs::File::create(path).unwrap();
|
||||||
|
let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
|
||||||
|
|
||||||
|
for (name, kind, data, link_target) in entries {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_entry_type(*kind);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_size(if link_target.is_some() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
data.len() as u64
|
||||||
|
});
|
||||||
|
|
||||||
|
// `append_data`/`set_path` reject `..` and absolute paths, so a hostile
|
||||||
|
// archive cannot be produced through the safe API. Write the raw name
|
||||||
|
// bytes into the GNU header directly — this is precisely what a
|
||||||
|
// malicious archiver does, and the only way to test the guard honestly.
|
||||||
|
write_raw_name(&mut header, name);
|
||||||
|
if let Some(target) = link_target {
|
||||||
|
write_raw_link(&mut header, target);
|
||||||
|
}
|
||||||
|
header.set_cksum();
|
||||||
|
|
||||||
|
builder.append(&header, *data).unwrap();
|
||||||
|
}
|
||||||
|
builder.into_inner().unwrap().finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrite the GNU header's `name` field with arbitrary bytes, bypassing the
|
||||||
|
/// validation `Header::set_path` performs.
|
||||||
|
fn write_raw_name(header: &mut tar::Header, name: &str) {
|
||||||
|
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||||
|
gnu.name = [0u8; 100];
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
assert!(bytes.len() < 100, "fixture name too long for a GNU header");
|
||||||
|
gnu.name[..bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same, for the `linkname` field.
|
||||||
|
fn write_raw_link(header: &mut tar::Header, target: &str) {
|
||||||
|
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||||
|
gnu.linkname = [0u8; 100];
|
||||||
|
let bytes = target.as_bytes();
|
||||||
|
assert!(bytes.len() < 100, "fixture link target too long");
|
||||||
|
gnu.linkname[..bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A theme directory just complete enough for `plan` to consider the archive.
|
||||||
|
fn theme_with_archive(entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) -> (TempDir, PathBuf, PathBuf) {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("Configs/.config/hyde/themes/Evil");
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::write(theme_dir.join("hypr.theme"), "general {\n gaps_in = 3\n}\n").unwrap();
|
||||||
|
|
||||||
|
hostile_tarball(&source_dir.join("Gtk_Evil.tar.gz"), entries);
|
||||||
|
(tmp, theme_dir, source_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything created outside the sandbox root is an escape.
|
||||||
|
fn assert_nothing_outside(canary: &Path) {
|
||||||
|
assert!(
|
||||||
|
!canary.exists(),
|
||||||
|
"archive extraction escaped its destination and wrote {}",
|
||||||
|
canary.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_dir_traversal_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"../../../../../../tmp/cosmic_conf_escape_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", true);
|
||||||
|
|
||||||
|
// Whether it is rejected at plan time or apply time, the invariant is the
|
||||||
|
// same: nothing lands outside the destination.
|
||||||
|
if let Ok(plan) = result {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_escape_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_path_entry_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"/tmp/cosmic_conf_abs_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtle one: neither entry path contains `..`, so a naive check passes.
|
||||||
|
/// The symlink redirects a later, innocent-looking write outside the tree.
|
||||||
|
#[test]
|
||||||
|
fn symlink_indirection_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[
|
||||||
|
("escape", tar::EntryType::Symlink, b"", Some("/tmp")),
|
||||||
|
(
|
||||||
|
"escape/cosmic_conf_symlink_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A benign archive must still install, or the guard is uselessly strict.
|
||||||
|
#[test]
|
||||||
|
fn well_formed_archive_still_installs() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"Evil-Theme/index.theme",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"[Desktop Entry]\n",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
let plan = installer
|
||||||
|
.plan(&theme_dir, Some(&source_dir), "Evil", true)
|
||||||
|
.expect("a well-formed archive must plan cleanly");
|
||||||
|
installer.apply(&plan).expect("and must apply");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
home.join(".themes/Evil-Theme/index.theme").exists(),
|
||||||
|
"benign archive did not install; guard is too strict"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user