diff --git a/.gitignore b/.gitignore
index be7ea401..0e11b667 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ src/*/*.lock
/*.conf
/test
/build
+*.snap
# ides
.idea
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index cef57c21..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "[python]": {
- "editor.defaultFormatter": "charliermarsh.ruff"
- },
- "python.testing.pytestArgs": ["."],
- "python.testing.unittestEnabled": false,
- "python.testing.pytestEnabled": true
-}
diff --git a/Cargo.lock b/Cargo.lock
index a6057d34..c3e64bd6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -56,7 +56,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -93,12 +93,24 @@ dependencies = [
"syn 2.0.111",
]
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -142,6 +154,12 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
[[package]]
name = "cc"
version = "1.2.51"
@@ -158,6 +176,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core",
+]
+
[[package]]
name = "chrono"
version = "0.4.42"
@@ -409,6 +444,7 @@ name = "debmagic"
version = "0.0.1-alpha.8"
dependencies = [
"anyhow",
+ "chrono",
"clap",
"config",
"deb822-lossless",
@@ -416,14 +452,19 @@ dependencies = [
"debian-control",
"debmagic-common",
"dirs",
+ "futures-util",
"glob",
"ignore",
"libc",
"md-5",
+ "regex",
+ "reqwest",
"serde",
"serde_json",
"sha1",
"sha2 0.11.0",
+ "test-case",
+ "tokio",
"toml",
"toml_edit",
"uuid",
@@ -433,10 +474,16 @@ dependencies = [
name = "debmagic-common"
version = "0.0.1-alpha.8"
dependencies = [
+ "anyhow",
"chrono",
+ "deb822-lossless",
+ "debian-changelog",
+ "md-5",
"regex",
"serde",
"serde_json",
+ "sha1",
+ "sha2 0.11.0",
"test-case",
]
@@ -490,7 +537,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -560,6 +607,67 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.111",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -577,8 +685,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -589,10 +699,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
]
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core",
+ "wasm-bindgen",
+]
+
[[package]]
name = "glob"
version = "0.3.3"
@@ -648,6 +772,45 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "hybrid-array"
version = "0.4.15"
@@ -657,6 +820,65 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "iana-time-zone"
version = "0.1.64"
@@ -809,6 +1031,12 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -823,11 +1051,12 @@ checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010"
[[package]]
name = "js-sys"
-version = "0.3.83"
+version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
- "once_cell",
+ "cfg-if",
+ "futures-util",
"wasm-bindgen",
]
@@ -867,18 +1096,19 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.178"
+version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libredox"
-version = "0.1.11"
+version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50"
+checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a"
dependencies = [
"bitflags",
"libc",
+ "plain",
"redox_syscall",
]
@@ -894,6 +1124,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
[[package]]
name = "md-5"
version = "0.11.0"
@@ -910,6 +1146,17 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -1021,6 +1268,24 @@ dependencies = [
"sha2 0.10.9",
]
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1039,6 +1304,62 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.3",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand",
+ "rand_pcg",
+ "ring",
+ "rustc-hash 2.1.3",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.42"
@@ -1054,11 +1375,43 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core",
+]
+
[[package]]
name = "redox_syscall"
-version = "0.6.0"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5"
+checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
dependencies = [
"bitflags",
]
@@ -1103,6 +1456,61 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.16",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "ron"
version = "0.12.0"
@@ -1125,7 +1533,7 @@ checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
dependencies = [
"countme",
"hashbrown 0.14.5",
- "rustc-hash",
+ "rustc-hash 1.1.0",
"text-size",
]
@@ -1145,12 +1553,59 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -1224,6 +1679,18 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "sha1"
version = "0.11.0"
@@ -1269,6 +1736,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
[[package]]
name = "smallvec"
version = "1.15.2"
@@ -1281,6 +1754,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1293,6 +1776,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.111"
@@ -1315,6 +1804,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "synstructure"
version = "0.13.2"
@@ -1415,6 +1913,61 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "toml"
version = "0.9.10+spec-1.1.0"
@@ -1467,6 +2020,76 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -1509,6 +2132,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1561,6 +2190,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -1584,9 +2222,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.106"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
@@ -1595,11 +2233,21 @@ dependencies = [
"wasm-bindgen-shared",
]
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.106"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1607,26 +2255,68 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.106"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 2.0.111",
+ "syn 3.0.3",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.106"
+version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "whoami"
version = "1.6.1"
@@ -1643,7 +2333,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1705,6 +2395,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1714,6 +2413,70 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "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.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
name = "winnow"
version = "0.7.14"
@@ -1790,6 +2553,12 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
[[package]]
name = "zerotrie"
version = "0.2.3"
diff --git a/Cargo.toml b/Cargo.toml
index 14113622..d0abf6c9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,9 @@ toml_edit = ">=0.22"
uuid = { version = ">=1.10.0", features = ["v4", "v5"] }
chrono = { version = ">=0.4.42" }
regex = { version = ">=1.12.2" }
+reqwest = { version = ">=0.12", default-features = false, features = ["rustls-tls", "stream"] }
+futures-util = "0.3"
+tokio = { version = ">=1.43", features = ["macros", "rt-multi-thread"] }
debian-changelog = { version = ">=0.2.14" }
debian-control = { version = ">=0.1.39" }
deb822-lossless = { version = ">=0.2" }
diff --git a/README.md b/README.md
index 13028075..2ecba0ae 100644
--- a/README.md
+++ b/README.md
@@ -2,54 +2,53 @@
-Unified and future-proof developer tooling for increased productivity in the [Debian](https://debian.org)/[Ubuntu](https://ubuntu.com) ecosystem.
+Modern, robust & easy [Debian](https://debian.org)/[Ubuntu](https://ubuntu.com) packaging - while staying backwards compatible.
-> [!IMPORTANT]
-> Debmagic's goal: make Debian packaging modern, robust & easy - while being backwards compatible.
+Debmagic unifies the the packaging experience as a single, streamlined tool. Additionally, `debmagic-pkg` allows you to define package build recipes in Python.
-Debmagic has two independent parts:
-- tooling to [**build** and **test**](#debmagic-building) packages
- - in isolated container environments (lxd, docker, ...)
- - `debmagic build`, `debmagic check`, `debmagic test`, `debmagic shell`, ...
-- create package [**build instructions**](#debmagic-packaging)
- - using Python with `debian/rules.py` (instead of shell & Makefiles)
+- **Isolated builds without the setup**: `debmagic build binary` builds a `debian/`-packaged source tree in a temporary container (using LXD, Incus, Docker)
+- **Fast iteration**: `--persistent`/`--incremental` reuse the environment and sync only source changes; `--shell-on-failure` and `debmagic shell` drop you right where the build broke
+- **Test & sign integrated**: `debmagic test` runs the package's autopkgtest tests in a fresh environment, `debmagic sign` GPG-signs `.changes`/`.dsc`/`.buildinfo` on the host
+- **Python packaging API**: replace complicated `debian/rules` Makefiles with typed Python `debian/rules.py`, with optional `dh` compatibility
-[](https://github.com/SFTtech/debmagic/actions/workflows/push_on_main.yaml)
+[](https://github.com/SFTtech/debmagic/actions)
+## Quickstart
----
-
-## Documentation
-
-To learn packaging with debmagic, follow **[the documentation!](https://debmagic.readthedocs.io)**.
+```shell
+cd your-package # any source tree with a debian/ directory
+uvx debmagic build binary --driver docker
+```
+> [!TIP]
+> `debmagic --help` lists everything.
-## Debmagic building
+### Commands
| Command | Goal |
| - | - |
-| `debmagic build binary` | Build a binary package in a container |
-| `debmagic build source` | Create a source package for upload |
-| `debmagic test` | Run Debian autopkgtest tests (`debian/tests/`) against a prior build |
-| `debmagic check` | Lint the package |
-
-> [!TIP]
-> Want to know more about how to use debmagic to build a package? See [docs/usage/build.md](docs/usage/build.md) for a quickstart.
+| `debmagic build binary` | Build a binary package (`.deb`) in an isolated environment |
+| `debmagic build source` | Create a source package (`.dsc`) for upload (incl signing) |
+| `debmagic test` | Run the package's autopkgtest tests (`debian/tests/`) against a prior build |
+| `debmagic shell` | Attach an interactive shell to the build environment |
+| `debmagic sign` | GPG-sign a `.changes` file (and its `.dsc`/`.buildinfo`) on the host |
+| `debmagic config` | Inspect and edit the effective `debmagic.toml` configuration |
+| `debmagic check` | Lint the package *(in progress)* |
-## Debmagic packaging
+---
-You can use the debmagic API to create package build instructions 🚀
+## Documentation
-- build steps in `debian/rules.py`:
- To consolidate the build recipes, we use the [Debmagic API modules](packages/debmagic-pkg/src/debmagic/v0/_module/) which provide a unified **high-level interface** to common build tools like `cargo`, `autotools`, `cmake`, `meson`, `go`, `ninja`, `python setup.py` and more.
+To learn using debmagic, follow **[the documentation!](https://debmagic.readthedocs.io)**.
-Usually, `debian/rules` is written as shell-oneliners in a **Makefile**.
+## Debmagic package recipes
-Debmagic allows straight-forward conversion to **Python**, which is especially useful if the packaging is more complex, like [openldap](https://git.launchpad.net/ubuntu/+source/openldap/tree/debian/rules?h=ubuntu/resolute-devel), [dovecot](https://git.launchpad.net/ubuntu/+source/dovecot/tree/debian/rules?h=ubuntu/resolute-devel), [samba](https://git.launchpad.net/ubuntu/+source/samba/tree/debian/rules?h=ubuntu/resolute-devel) or [gcc](https://git.launchpad.net/ubuntu/+source/gcc-15/tree/debian/rules?h=ubuntu/resolute-devel).
+You can use the debmagic pkg API to create package build instructions (`debian/rules.py`), using [Debmagic API modules](packages/debmagic-pkg/src/debmagic/v0/_module/) for common build tools like `cargo`, `autotools`, `cmake`, `meson`, `go`, `ninja`, `python` `setup.py`/`pyproject.toml` and more.
-We provide an optional `dh` sequence backward compatibility [module](packages/debmagic-pkg/src/debmagic/v0/_module/dh.py).
+While simple shell oneliners in a state-of-the art `debian/rules` **Makefile** can suffice for simple packages, packaging more complex projects like [openldap](https://git.launchpad.net/ubuntu/+source/openldap/tree/debian/rules?h=ubuntu/resolute-devel), [dovecot](https://git.launchpad.net/ubuntu/+source/dovecot/tree/debian/rules?h=ubuntu/resolute-devel), [samba](https://git.launchpad.net/ubuntu/+source/samba/tree/debian/rules?h=ubuntu/resolute-devel) or [gcc](https://git.launchpad.net/ubuntu/+source/gcc-15/tree/debian/rules?h=ubuntu/resolute-devel) can benefit from a more structured approach with `debmagic`.
+To simplify the conversion of existing packages, we provide an optional `dh` sequence backward compatibility [module](packages/debmagic-pkg/src/debmagic/v0/_module/dh.py).
### Example debian/rules.py
@@ -87,55 +86,6 @@ def configure(build: Build):
pkg.pack()
```
-### debhelper compatibility
-
-For even more straightforward conversion of `debian/rules` Makefiles, Debmagic can [use `dh`](packages/debmagic-pkg/src/debmagic/v0/_module/dh.py) and provides **dh overrides**:
-
-```python
-from debmagic.v0 import dh
-
-# specify dh arguments:
-dhp = dh.Preset("--with=python3 --builddirectory=build")
-pkg = package(preset=dhp)
-
-# if needed, define optional overrides:
-@dhp.override
-def dh_auto_install(build: Build):
- print("dh override worked :)")
- build.cmd("dh_auto_install --max-parallel=1")
-
-pkg.pack()
-```
-
-### Custom functions
-
-To add custom functions directly usable from CLI (like custom `debian/rules` targets for maintainers):
-
-```python
-pkg = package(...)
-
-@pkg.custom_function
-def something_custom(some_param: int, another_param: str = "some default"):
- print(f"you passed {some_param=} {another_param=}")
-
-pkg.pack()
-```
-
-This function can be directly called with:
-
-```console
-./debian/rules.py something-custom --another-param=test 1337
-```
-
-```text
-you passed some_param=test another_param=1337
-```
-
-And generates automatic help for:
-
-```console
-./debian/rules.py something-custom --help
-```
## Contributing
diff --git a/debian/control b/debian/control
index 026892aa..bc843afb 100644
--- a/debian/control
+++ b/debian/control
@@ -26,6 +26,9 @@ Build-Depends:
librust-uuid-dev (>=1.10.0),
librust-chrono-dev (>=0.4.42),
librust-regex-dev (>=1.12.2),
+ librust-reqwest-dev (>= 0.12),
+ librust-futures-util-dev (>= 0.3),
+ librust-tokio-dev (>= 1.43),
librust-test-case-dev (>=3.3.1),
librust-pyo3-dev (>=0.27.2),
librust-deb822-lossless-dev (>=0.5.18),
@@ -34,7 +37,7 @@ Build-Depends:
librust-ignore-dev (>=0.4.25),
librust-md-5-dev (>= 0.11),
librust-sha1-dev (>= 0.11),
- librust-sha2-dev (>= 0.11)
+ librust-sha2-dev (>= 0.11),
Rules-Requires-Root: no
X-Style: black
Standards-Version: 4.7.2
@@ -54,11 +57,26 @@ Multi-Arch: foreign
Description: Debian build instructions written in Python.
Explicit is better than implicit.
+Package: debmagic-dpkg-driver
+Architecture: all
+Depends:
+ dpkg-dev (>= 1.23.6),
+ ${misc:Depends}
+Recommends:
+ debmagic-pkg
+Description: dpkg build driver for debmagic rules.py packages.
+ Packages declaring `Build-Driver: debmagic` are built from `debian/rules.py`
+ instead of a `debian/rules` Makefile. This package provides the
+ `Dpkg::BuildDriver::Debmagic` perl module that `dpkg-buildpackage` loads to
+ dispatch the dpkg build targets to the python entry point.
+
Package: debmagic
Architecture: any
Depends:
${misc:Depends},
${shlibs:Depends},
+Suggests:
+ debmagic-dpkg-driver
Description: Debian package building made easy.
Holistic cli for the whole debian package building workflow.
Built-Using: ${cargo:Built-Using}
diff --git a/debian/debmagic-dpkg-driver.install b/debian/debmagic-dpkg-driver.install
new file mode 100644
index 00000000..8ccf73e2
--- /dev/null
+++ b/debian/debmagic-dpkg-driver.install
@@ -0,0 +1 @@
+packages/debmagic-dpkg-driver/Debmagic.pm usr/share/perl5/Dpkg/BuildDriver/
\ No newline at end of file
diff --git a/debian/debmagic.1 b/debian/debmagic.1
index f0d3cfee..f1b68553 100644
--- a/debian/debmagic.1
+++ b/debian/debmagic.1
@@ -1,11 +1,12 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3.
-.TH DEBMAGIC "1" "July 2026" "debmagic 0.0.1-alpha1" "User Commands"
+.TH DEBMAGIC "1" "September 2026" "debmagic 0.0.1-alpha.8" "User Commands"
.SH NAME
-debmagic \- build Debian packages in isolated environments
+debmagic \- manual page for debmagic 0.0.1-alpha.8
.SH SYNOPSIS
.B debmagic
[\fI\,OPTIONS\/\fR] \fI\,\/\fR
.SH DESCRIPTION
+tooling for building and packaging Debian/Ubuntu packages in isolated environments
.SS "Commands:"
.TP
build
@@ -15,23 +16,49 @@ shell
Open an interactive shell to the currently active build environment
.TP
test
-Run tests
+Run the package's declared Debian autopkgtest tests against a prior build
.TP
check
Check the project
.TP
+sign
+GPG\-sign a .changes file (and its .dsc/.buildinfo) on the host
+.TP
+upload
+Upload a .changes file (and everything it references) to an upload target, dput\-style
+.TP
+config
+Inspect the debmagic configuration
+.TP
+upstream
+Query and switch upstream versions
+.TP
version
Show version information
.TP
help
Print this message or the help of the given subcommand(s)
.SH OPTIONS
-.TP
+.HP
\fB\-c\fR, \fB\-\-config\fR
+.IP
Path to config file
-.TP
+.HP
+\fB\-\-color\fR
+.IP
+When to colorize output: 'auto' (default) colors on a terminal and respects NO_COLOR, 'always' forces color, 'never' disables it
+.IP
+Possible values:
+\- auto: Color when stderr is a terminal and `NO_COLOR` is unset
+\- always: Always color, even when piped or `NO_COLOR` is set
+\- never: Never color
+.IP
+[default: auto]
+.HP
\fB\-h\fR, \fB\-\-help\fR
-Print help
-.TP
+.IP
+Print help (see a summary with '\-h')
+.HP
\fB\-V\fR, \fB\-\-version\fR
+.IP
Print version
diff --git a/debian/rules b/debian/rules
index 2d216ecc..303d7289 100755
--- a/debian/rules
+++ b/debian/rules
@@ -1,71 +1,12 @@
-#!/usr/bin/env python3
-import shutil
-import os
+#!/usr/bin/make -f
-import sys
-from pathlib import Path
+# This is a self-build shim: All dpkg build targets are forwarded to the
+# real packaging logic in debian/rules.py.
+# d/control `Build-driver: debmagic`` (debmagic-dpkg-driver provides Dpkg::BuildDriver::Debmagic) can directly invoke debian/rules.py once installed.
-packages_root = Path(__file__).parent.parent / "packages"
-sys.path.append(str(packages_root / "debmagic-pkg" / "src"))
+PYTHON ?= python3
-from debmagic.v0 import package, dh, Build
+%:
+ $(PYTHON) debian/rules.py $@
-dhp = dh.Preset(dh_args=["--with", "python3", "--buildsystem=pybuild"])
-
-pkg = package(
- preset=[dhp],
-)
-
-# TODO: add this section to our rust debmagic module to make setting this up less annoying
-os.environ.update({
- "PATH": f"/usr/share/cargo/bin:{os.environ['PATH']}",
- "CARGO": "/usr/share/cargo/bin/cargo",
- "CARGO_HOME": f"{pkg.base_dir}/debian/cargo_home",
- "CARGO_REGISTRY": f"{pkg.base_dir}/debian/cargo_registry",
- "DEB_CARGO_CRATE": f"{pkg.build_env.DEB_SOURCE}_{pkg.build_env.DEB_VERSION_UPSTREAM}"
-})
-
-packages = {
- "debmagic": ("packages/debmagic", "debmagic"),
- "debmagic-pkg": ("packages/debmagic-pkg", "debmagic-pkg"),
-}
-
-cargo_lock = Path("Cargo.lock")
-cargo_lock_saved = Path("Cargo.lock.saved")
-
-def dh_auto(build: Build, stage: str, use_destdir: bool = False):
- for pkg_name, (path, python_pkg_name) in packages.items():
- destdir = f" --destdir debian/{pkg_name} " if use_destdir else ""
- build.cmd(f"{stage} -p {pkg_name} --sourcedirectory {path} --buildsystem=pybuild {destdir} -- --name {python_pkg_name}")
-
-@dhp.override
-def dh_auto_configure(build: Build):
- build.cmd("cargo prepare-debian debian/cargo_registry --link-from-system", cwd="packages/debmagic")
- dh_auto(build, "dh_auto_configure")
-
-
-@dhp.override
-def dh_auto_build(build: Build):
- if cargo_lock.is_file():
- shutil.move(cargo_lock, cargo_lock_saved)
- dh_auto(build, "dh_auto_build")
-
-
-@dhp.override
-def dh_auto_install(build: Build):
- dh_auto(build, "dh_auto_install", use_destdir=True)
-
-
-@dhp.override
-def dh_auto_test(build: Build):
- dh_auto(build, "dh_auto_test")
-
-
-@dhp.override
-def dh_auto_clean(build: Build):
- if cargo_lock_saved.is_file():
- shutil.move(cargo_lock_saved, cargo_lock)
- dh_auto(build, "dh_auto_clean")
-
-
-pkg.pack()
+.PHONY: clean build build-arch build-indep binary binary-arch binary-indep
diff --git a/debian/rules.py b/debian/rules.py
new file mode 100755
index 00000000..9c3bb05d
--- /dev/null
+++ b/debian/rules.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+import shutil
+import os
+
+import sys
+from pathlib import Path
+
+packages_root = Path(__file__).parent.parent / "packages"
+sys.path.append(str(packages_root / "debmagic-pkg" / "src"))
+
+from debmagic.v0 import package, dh, Build
+
+dhp = dh.Preset(dh_args=["--with", "python3", "--buildsystem=pybuild"])
+
+pkg = package(
+ preset=[dhp],
+)
+
+# TODO: add this section to our rust debmagic module to make setting this up less annoying
+os.environ.update({
+ "PATH": f"/usr/share/cargo/bin:{os.environ['PATH']}",
+ "CARGO": "/usr/share/cargo/bin/cargo",
+ "CARGO_HOME": f"{pkg.base_dir}/debian/cargo_home",
+ "CARGO_REGISTRY": f"{pkg.base_dir}/debian/cargo_registry",
+ "DEB_CARGO_CRATE": f"{pkg.build_env.DEB_SOURCE}_{pkg.build_env.DEB_VERSION_UPSTREAM}"
+})
+
+packages = {
+ "debmagic": ("packages/debmagic", "debmagic"),
+ "debmagic-pkg": ("packages/debmagic-pkg", "debmagic-pkg"),
+}
+
+cargo_lock = Path("Cargo.lock")
+cargo_lock_saved = Path("Cargo.lock.saved")
+
+def dh_auto(build: Build, stage: str, use_destdir: bool = False):
+ for pkg_name, (path, python_pkg_name) in packages.items():
+ destdir = f" --destdir debian/{pkg_name} " if use_destdir else ""
+ build.cmd(f"{stage} -p {pkg_name} --sourcedirectory {path} --buildsystem=pybuild {destdir} -- --name {python_pkg_name}")
+
+@dhp.override
+def dh_auto_configure(build: Build):
+ build.cmd("cargo prepare-debian debian/cargo_registry --link-from-system", cwd="packages/debmagic")
+ dh_auto(build, "dh_auto_configure")
+
+
+@dhp.override
+def dh_auto_build(build: Build):
+ if cargo_lock.is_file():
+ shutil.move(cargo_lock, cargo_lock_saved)
+ dh_auto(build, "dh_auto_build")
+
+
+@dhp.override
+def dh_auto_install(build: Build):
+ dh_auto(build, "dh_auto_install", use_destdir=True)
+
+
+@dhp.override
+def dh_auto_test(build: Build):
+ dh_auto(build, "dh_auto_test")
+
+
+@dhp.override
+def dh_auto_clean(build: Build):
+ if cargo_lock_saved.is_file():
+ shutil.move(cargo_lock_saved, cargo_lock)
+ dh_auto(build, "dh_auto_clean")
+
+
+pkg.pack()
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index ffd3ac8d..ceb0e54a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -23,6 +23,8 @@
"html_image",
"attrs_inline",
]
+# generate heading ids for intra-page anchor links
+myst_heading_anchors = 3
templates_path = ["_templates"]
exclude_patterns = ["_build"]
diff --git a/docs/develop/index.md b/docs/develop/index.md
index 3bc9d422..523f3809 100644
--- a/docs/develop/index.md
+++ b/docs/develop/index.md
@@ -8,6 +8,7 @@ codebase, and some guidelines for how you can contribute.
Prerequisites:
- Debian >= trixie, either roll your own environment or to get started faster use the [devcontainer](https://containers.dev/)
+- Rust >= edition 2024
- Python >= 3.12
- [UV](https://docs.astral.sh/uv/)
@@ -18,6 +19,26 @@ uv sync
uv run pre-commit install
```
+Build and test:
+
+```shell
+# Rust CLI and shared crate
+cargo build
+cargo test
+cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
+
+# Python packaging API
+uv run pytest --ignore tests/integration .
+uv run ruff check
+uv run ty check .
+```
+
+Integration tests (end-to-end builds of real packages):
+
+```shell
+uv run pytest tests/integration
+```
+
Build the documentation
```shell
@@ -27,6 +48,18 @@ uv run sphinx-build docs docs/_build
uv run sphinx-autobuild docs docs/_build
```
+## Architecture: `debmagic-common` vs `debmagic`
+
+We separate between the Debian package model from packaging actions:
+
+- `packages/debmagic-common` — **pure Debian packaging models, their parsing, and their in-place transformation**, with no I/O side effects.
+The `package::Package` is the entry point; `debian::*` holds all substructure models (version, changelog, source format, ...).
+- `packages/debmagic` — **packaging CLI** that does all IO (and manages environments) while using the models from `debmagic-common`.
+
+The layering rule: **common owns bytes ↔ structure (parse, transform, serialize); consumers own paths ↔ bytes**.
+Common never touches a file descriptor — a consumer that wants mmap hands common the `&str`/`&[u8]` and writes the
+serialized result itself.
+
```{toctree}
```
diff --git a/docs/index.md b/docs/index.md
index 6e2e4f15..bff6dd8e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,6 +2,34 @@
{width=250px align=center}
+Modern, robust & easy [Debian](https://debian.org)/[Ubuntu](https://ubuntu.com) packaging - while staying backwards compatible.
+
+Debmagic unifies the the packaging experience as a single, streamlined tool. Additionally, `debmagic-pkg` allows you to define package build recipes in Python.
+Working with Debian packages without `debmagic` means juggling with several independent tools like `sbuild`/`pbuilder` chroots, `dch`, `dpkg-buildpackage`, `debsign`, `autopkgtest` virt-backends, `dput`, `lintian`, and various custom glue scripts.
+Debmagic unifies this into one CLI with useful defaults:
+
+- **Isolated builds without the setup**: `debmagic build binary` builds a `debian/`-packaged source tree in a temporary container (using LXD, Incus, Docker)
+- **Fast iteration**: `--persistent`/`--incremental` reuse the environment and sync only source changes; `--shell-on-failure` and `debmagic shell` drop you right where the build broke
+- **Test & sign integrated**: `debmagic test` runs the package's autopkgtest tests in a fresh environment, `debmagic sign` GPG-signs `.changes`/`.dsc`/`.buildinfo` on the host
+- **Package uploading**: `debmagic upload` (to `debian`, `ubuntu`, `ppa:user/repo`) pushes signed builds to archives and PPAs via scp/sftp, with configurable pre-upload checks
+- **Upstream tracking**: `debmagic upstream list`/`switch` replace uscan/uupdate, reading the package's existing `debian/watch` and `debian/copyright`
+- **Python packaging API**: replace complicated `debian/rules` Makefiles with typed Python `debian/rules.py`, with optional `dh` compatibility
+
+[](https://github.com/SFTtech/debmagic/actions)
+
+## Quickstart
+
+```shell
+cd your-package # any source tree with a debian/ directory
+uvx debmagic build binary --driver docker
+```
+
+> [!TIP]
+> `debmagic --help` lists everything.
+
+
+Then dive into the pages below — start with [Getting started](usage/getting-started.md).
+
```{toctree}
:hidden:
:caption: Usage
@@ -10,7 +38,10 @@ usage/getting-started.md
usage/build.md
usage/test.md
usage/source.md
+usage/upload.md
+usage/upstream.md
usage/config.md
+usage/packaging.md
usage/modules/index.md
```
diff --git a/docs/usage/build.md b/docs/usage/build.md
index bc0f7767..d5e5762a 100644
--- a/docs/usage/build.md
+++ b/docs/usage/build.md
@@ -29,7 +29,9 @@ debmagic build binary --driver lxd \
| `--sign` | [GPG-sign the resulting `.changes`/`.dsc`/`.buildinfo`](#signing) |
| `--clean` | Run [`debian/rules clean` before building](#cleaning) |
| `--debug-symbols` | [Build the automatic `-dbgsym` debug symbol packages](#building-debug-symbol-packages) |
+| `--test` | [Run the package's test suite](#running-tests) |
| `--apt-mirror ` | [Mirror URL](#mirror-selection) |
+| `--apt-update-age ` | [When a persistent environment re-runs `apt-get update`](#apt-update-age) |
| `--source-dir ` | Directory containing the `debian/` package directory |
| `--output-dir ` | Directory to put the resulting build artifacts |
| `--shell-on-failure` | On build failure, drop into an interactive shell in the build environment when stdout is a TTY |
@@ -75,6 +77,20 @@ debmagic build binary --driver lxd --apt-mirror http:///ubuntu ...
You can persistently set this flag in `.config/debmagic/config.toml`.
+## Apt update age
+
+Fresh environments always run `apt-get update` once on creation.
+When a persistent environment is reused, `--apt-update-age` decides whether the apt index is refreshed again:
+
+| Value | Behavior |
+|---|---|
+| `now` | Update before every build |
+| `never` | Only the initial update on creation |
+| `1d` (default), `12h`, `30m`, … | Update again once the last one is older than this |
+
+The default `1d` matches a typical developer machine's daily apt refresh: repeated builds stay fast, while the index can't go arbitrarily stale.
+Persist the setting as `apt_update_age = "1d"` in [`debmagic.toml`](config.md).
+
## Source file staging
Before building, `debmagic` stages the source tree into the build environment.
@@ -118,10 +134,13 @@ The preserved build tree is kept even when the environment itself is *not* reuse
Only needed when `debian/changelog`'s top entry doesn't unambiguously determine the target: pass `--distro ` (e.g. `--distro noble`, `--distro trixie`).
If the changelog has a single unambiguous entry, omit it.
+`--distro` also overrides the changelog's distribution: you can use it to rebuild a package released for an older release on a newer one, or to attempt a backport.
+On the Bare driver the target must still match the host's os-release; pass `--bare-ignore-release` to build for a different suite anyway, with the host providing the build dependencies itself.
+
Suite aliases in the changelog (or via `--distro`) resolve to a concrete release: Debian `stable` / `oldstable` / `sid` (→ `unstable`), and Ubuntu `devel`.
Alias targets are updated manually when Debian/Ubuntu roll.
-Non-Debian/Ubuntu suites (still apt/dpkg-based) are supported when declared for the active container Driver via `base_images`, e.g. `driver.docker.base_images = { "yocto:kirkstone" = "my-registry/yocto-kirkstone:latest" }`. The changelog/`--distro` value stays the bare codename (`kirkstone`). On the Bare driver, the host `/etc/os-release` must match: built-in Debian/Ubuntu need matching `ID` and codename; other suites need a matching `VERSION_CODENAME` only.
+Non-Debian/Ubuntu suites (still apt/dpkg-based) are supported when declared for the active container Driver via `base_images`, e.g. `driver.docker.base_images = { "yocto:kirkstone" = "my-registry/yocto-kirkstone:latest" }`. The changelog/`--distro` value stays the bare codename (`kirkstone`). On the Bare driver, binary builds require the host `/etc/os-release` to match: built-in Debian/Ubuntu need matching `ID` and codename, other suites a matching `VERSION_CODENAME`.
## Proposed dependencies
@@ -139,6 +158,17 @@ debmagic build binary --debug-symbols --output-dir /tmp/out
Or set `build_debug_symbols = true` in the [`debmagic.toml`](config.md).
+## Running tests
+
+By default the build runs the package's test suite (the `test` stage of `debian/rules.py`, or `dh_auto_test` via the dh preset).
+Pass `--test=false` to skip it for one invocation, or set `run_test = false` in the [`debmagic.toml`](config.md):
+
+```shell
+debmagic build binary --test=false
+```
+
+This exports `DEB_BUILD_OPTIONS=nocheck`, the standard dpkg mechanism: dpkg-buildpackage propagates it into the build, debmagic's `test` stage is skipped, and classic debhelper packages skip `dh_auto_test` as usual.
+
## Signing
`--sign` GPG-signs the resulting `.changes`/`.dsc`/`.buildinfo` after building — mainly useful for [source builds destined for Launchpad](source.md#uploading-to-launchpad), but works for binary builds too.
@@ -150,7 +180,7 @@ Children are signed first (`.dsc`, then `.buildinfo`) and the `.changes` checksu
| `--sign` | `sign.source` | Sign after building; `--sign=false` skips it for one invocation |
| `--sign-key ` | `sign.key` | Key ID/fingerprint/email; defaults to the `Changed-By:`/`Maintainer:` address of the file being signed |
| `--sign-tool ` | `sign.tool` | OpenPGP implementation: `gpg` (default), `sequoia` (sq), or `custom` |
-| `--sign-command ` | `sign.command` | Custom signing command for `--sign-tool custom` (see below) |
+| `--sign-command ` | `sign.sign_command` | Custom signing command for `--sign-tool custom` (see below) |
| `--sign-notify` | `sign.notify` | Desktop notification + terminal bell just before signing, so a hardware-key touch prompt isn't missed after a long build |
A custom signing command runs without a shell and must write the clearsigned result to stdout.
diff --git a/docs/usage/config.md b/docs/usage/config.md
index c4c0c68d..c2c20ce0 100644
--- a/docs/usage/config.md
+++ b/docs/usage/config.md
@@ -24,6 +24,7 @@ All keys are optional.
| `driver.persistent` | bool | `false` | `--persistent` | Keep and reuse the build environment across runs instead of tearing it down. |
| `driver.apt_mirror` | string | — | `--apt-mirror` | Mirror used for build-dependency resolution. Not used by the `bare` driver. |
| `driver.proposed` | bool | `false` | `--proposed` | Also enable the `-proposed` pocket. Not used by the `bare` driver. |
+| `driver.apt_update_age` | string | `"1d"` | `--apt-update-age` | When a persistent environment re-runs `apt-get update`: `"now"` (every build), `"never"` (only on creation), or a maximum index age like `"1d"`, `"12h"`, `"30m"`. Fresh environments always update once. Not used by the `bare` driver. |
| `driver.docker.base_images` | map | — | — | Base image per distro, keyed by `":"` (e.g. `"debian:trixie"`). Falls back to `docker.io/:`. For non-Debian/Ubuntu suites (e.g. `"yocto:kirkstone"`), the map entry is what makes the suite a known DistroVersion for Docker builds. |
| `driver.lxd.project` | string | — | — | LXD/Incus project to use. |
| `driver.lxd.base_images` | map | — | — | Base image per distro, keyed by `":"`. Falls back to the driver's default remote image. Same custom-suite registry role as Docker's map for LXD/Incus. |
@@ -32,14 +33,17 @@ All keys are optional.
| `incremental` | bool | `false` | `--incremental` | Retain the environment and sync only source changes, preserving generated files. Binary-only; implies `persistent`; incompatible with `clean`. |
| `source_sync_mode` | enum | `tracked` | `--source-sync` | Which source files are staged (see below). |
| `build_debug_symbols` | bool | `false` | `--debug-symbols` | Build the automatic `-dbgsym` debug symbol package. |
+| `run_test` | bool | `true` | `--test` | Run the package's test suite during the build; `false` exports `DEB_BUILD_OPTIONS=nocheck` so tests are skipped. |
| `sign.source` | bool | `false` | `--sign` | Sign the resulting `.changes`/`.dsc` (see below). |
| `sign.key` | string | — | `--sign-key` | GPG key ID/email to sign with; falls back to the Changed-By/Maintainer address. |
-| `sign.tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `sign.command`). |
-| `sign.command` | string | — | `--sign-command` | Custom signing command for `sign.tool = "custom"`, run without a shell with `{file}`/`{key}`/`{email}` placeholders; writes the clearsigned result to stdout. |
+| `sign.tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `sign.sign_command`). |
+| `sign.sign_command` | string | — | `--sign-command` | Custom signing command for `sign.tool = "custom"`, run without a shell with `{file}`/`{key}`/`{email}` placeholders; writes the clearsigned result to stdout. |
| `sign.notify` | bool | `false` | `--sign-notify` | Send a desktop notification via `notify-send` just before signing, so a hardware-key touch prompt isn't missed. |
| `clean` | bool | `false` | `--clean` | Run `debian/rules clean` before building. Disabled by default; incompatible with `incremental`. |
| `shell_on_failure` | bool | `false` | `--shell-on-failure` | On build or test failure, drop into an interactive shell in the environment when stdout is a TTY. |
| `host_arch_variant` | string | — | `--host-arch-variant` | Build for a dpkg architecture variant (e.g. `"amd64v3"` on Ubuntu) -> `DEB_HOST_ARCH_VARIANT`. |
+| `upstream.verify_signatures` | bool | `true` | `--no-signature-check` | Verify the upstream tarball's PGP signature against `debian/upstream/signing-key.asc` during `upstream switch` (see [Upstream](upstream.md#signature-verification)). |
+| `upload.targets` | map | — | `debmagic upload ` | Named upload targets for [uploading](upload.md), merged field-by-field over the builtins (`ppa`, `ubuntu`, `debian`). |
### `source_sync_mode`
@@ -55,12 +59,37 @@ All keys are optional.
|---|---|---|---|---|
| `source` | bool | `false` | `--sign` | Sign the source package (`.changes`/`.dsc`) after building. |
| `key` | string | — | `--sign-key` | GPG key ID/email to sign with; falls back to the Changed-By/Maintainer address. |
-| `tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `command`). |
-| `command` | string | — | `--sign-command` | Custom signing command for `tool = "custom"`, like `debsign`'s `-p`. |
+| `tool` | enum | `gpg` | `--sign-tool` | OpenPGP implementation: `gpg`, `sequoia` (sq) or `custom` (uses `sign_command`). |
+| `sign_command` | string | — | `--sign-command` | Custom signing command for `tool = "custom"`, like `debsign`'s `-p`. |
| `notify` | bool | `false` | `--sign-notify` | Desktop notification via `notify-send` before signing. |
-
+| `verify_command` | string | — | `--verify-command` (upstream switch) | Custom verification command for `sign.tool = "custom"`, run without a shell with `{file}`/`{signature}`/`{keyring}` placeholders; without `{signature}` the signature path is appended. Falls back to `sign_command`. |
Signing always runs on the host with your gpg keyring — see [Signing](build.md#signing).
+### `orig_tarball`
+
+Where `debmagic build source` fetches the `orig` tarball from when it isn't already in the output dir or next to the source tree — see [Upstream](upstream.md#orig-tarballs-in-builds).
+
+| Key | Type | Default | Description |
+|---|---|---|---|
+| `method` | enum | `launchpad` | `launchpad` (the distro archives via Launchpad's download URLs: Ubuntu first, then Debian), `debian` (Debian's own archive pool, without Launchpad), `ubuntu` (Ubuntu's own archive pool, without Launchpad), `custom` (requires `command`) or `disabled` (never fetch; build only with tarballs found locally). |
+| `command` | string | — | Custom fetch command for `method = "custom"`, run via `sh -c` in the source dir with `{name}`/`{version}`/`{upstream_version}`/`{source_dir}`/`{output_dir}` placeholders. |
+| `debian_mirror` | string | `https://deb.debian.org/debian` | Mirror root for `method = "debian"`, like an apt sources entry; `/pool` is appended. |
+| `ubuntu_mirror` | string | `http://archive.ubuntu.com/ubuntu` | Mirror root for `method = "ubuntu"`; `/pool` is appended. |
+
+### `upload`
+
+See [Uploading](upload.md) for target resolution, builtins and pre-upload checks.
+
+| Key | Type | Default | Description |
+|---|---|---|---|
+| `targets..method` | enum | `scp` | Upload method: `scp` or `sftp`. |
+| `targets..server` | string | — | Host to upload to. |
+| `targets..incoming` | string | — | Remote directory; supports the `{target}` placeholder. |
+| `targets..login` | string | ssh config | Login on the remote host; falls back to the ssh config user, then the local user. The Launchpad/Debian upload hosts expect your own username with a registered SSH key. |
+| `targets..port` | int | — | Remote port. |
+| `targets..tofu_hostkey` | bool | `true` | Trust the host key on first use (ssh's `StrictHostKeyChecking=accept-new`). The archive upload hosts publish stable keys; disable to require a known_hosts entry. |
+| `targets..pre_upload_commands` | list | `[]` | Commands run via `sh -c` before uploading; non-zero aborts the upload. |
+
## Example
diff --git a/docs/usage/getting-started.md b/docs/usage/getting-started.md
index 30415176..8052f56e 100644
--- a/docs/usage/getting-started.md
+++ b/docs/usage/getting-started.md
@@ -10,16 +10,18 @@ cargo install debmagic
### Pip
+Run it directly with [`uv`](https://docs.astral.sh/uv/):
+
```shell
-pip install debmagic
+uvx debmagic
```
-or run it directly with uv:
-
+or install using `pip`:
```shell
-uvx debmagic
+pip install debmagic
```
+
### Debian / Ubuntu - Soon (tm)
```shell
@@ -30,90 +32,20 @@ apt install debmagic
`debmagic build` builds *any* Debian-packaged source tree inside a throwaway build environment, driven by a build driver.
-To learn about `debmagic build`, see the [Building packages](build.md) page.
-
-## Example debian/rules.py
-
-Python `debian/rules.py` equivalent of [Ubuntu 24.04 htop](https://git.launchpad.net/ubuntu/+source/htop/tree/debian/rules?h=ubuntu/noble):
-
-```python
-#!/usr/bin/env python3
-
-from debmagic.v0 import Build, autotools, dh, package
-
-pkg = package(
- preset=[dh],
- maint_options="hardening=+all",
-)
-
-if pkg.buildflags.DEB_HOST_ARCH_OS == "linux":
- configure_params = ["--enable-affinity", "--enable-delayacct"]
-else:
- configure_params = ["--enable-hwloc"]
-
-# hurd-i386 can open /proc (nothing there) and /proc/ which works
-if pkg.buildflags.DEB_HOST_ARCH_OS == "hurd":
- configure_params += ["--with-proc=/proc/"]
-else:
- configure_params += ["--enable-sensors"]
-
-
-@pkg.stage
-def configure(build: Build):
- autotools.configure(
- build,
- ["--enable-openvz", "--enable-vserver", "--enable-unicode", *configure_params],
- )
-
-pkg.pack()
-```
-
-### debhelper compatibility
-
-Debmagic can use `dh` and provides **dh overrides** as common in `debian/rules` Makefiles:
-
-```python
-from debmagic.v0 import dh
-
-# specify dh arguments:
-dhp = dh.Preset("--with=python3 --builddirectory=build")
-pkg = package(preset=dhp)
-
-# define optional overrides:
-@dhp.override
-def dh_auto_install(build: Build):
- print("dh override worked :)")
- build.cmd("dh_auto_install --max-parallel=1")
-
-pkg.pack()
-```
-
-### Custom functions
-
-To add custom functions directly usable from CLI (like custom `debian/rules` targets for maintainers):
-
-```python
-pkg = package(...)
-
-@pkg.custom_function
-def something_custom(some_param: int, another_param: str = "some default"):
- print(f"you passed {some_param=} {another_param=}")
-
-pkg.pack()
-```
-
-This function can be directly called with:
-
-```console
-./debian/rules.py something-custom --another-param=test 1337
+```shell
+cd your-package # any source tree with a debian/ directory
+debmagic build binary --driver docker
```
-```text
-you passed some_param=test another_param=1337
-```
+The driver picks the isolation technology — `lxd`, `incus`, `docker` (full container isolation) or `bare` (no isolation, for disposable/CI environments).
+There's no auto-detection; pass one explicitly or set it in a [`debmagic.toml`](config.md).
-And generates automatic help for:
+From here:
-```console
-./debian/rules.py something-custom --help
-```
+- [Building packages](build.md) — all `debmagic build` options: drivers, distro selection, incremental builds, signing, ...
+- [Running package tests](test.md) — `debmagic test` against a prior build
+- [Building source packages](source.md) — `debmagic build source` and uploading to Launchpad
+- [Uploading](upload.md) — `debmagic upload`, upload targets and pre-upload checks
+- [Upstream management](upstream.md) — `debmagic upstream list`/`switch` for version bumps & backports
+- [Configuration](config.md) — persistent settings in `debmagic.toml`
+- [Creating package recipes](packaging.md) — writing `debian/rules.py` equivalents with `debmagic-pkg`
\ No newline at end of file
diff --git a/docs/usage/packaging.md b/docs/usage/packaging.md
index d056dd0e..05d919d3 100644
--- a/docs/usage/packaging.md
+++ b/docs/usage/packaging.md
@@ -2,4 +2,113 @@
`debmagic-pkg` allows you to write a package build recipe in Python.
-The documentation is not yet existent.
\ No newline at end of file
+
+## Example debian/rules.py
+
+Python `debian/rules.py` equivalent of [Ubuntu 24.04 htop](https://git.launchpad.net/ubuntu/+source/htop/tree/debian/rules?h=ubuntu/noble):
+
+```python
+#!/usr/bin/env python3
+
+from debmagic.v0 import Build, autotools, dh, package
+
+pkg = package(
+ preset=[dh],
+ maint_options="hardening=+all",
+)
+
+if pkg.buildflags.DEB_HOST_ARCH_OS == "linux":
+ configure_params = ["--enable-affinity", "--enable-delayacct"]
+else:
+ configure_params = ["--enable-hwloc"]
+
+# hurd-i386 can open /proc (nothing there) and /proc/ which works
+if pkg.buildflags.DEB_HOST_ARCH_OS == "hurd":
+ configure_params += ["--with-proc=/proc/"]
+else:
+ configure_params += ["--enable-sensors"]
+
+
+@pkg.stage
+def configure(build: Build):
+ autotools.configure(
+ build,
+ ["--enable-openvz", "--enable-vserver", "--enable-unicode", *configure_params],
+ )
+
+pkg.pack()
+```
+
+
+### debhelper compatibility
+
+For even more straightforward conversion of `debian/rules` Makefiles, Debmagic can run [`dh` sequences](packages/debmagic-pkg/src/debmagic/v0/_module/dh.py) and provides **dh overrides**:
+
+```python
+from debmagic.v0 import dh
+
+# specify dh arguments:
+dhp = dh.Preset("--with=python3 --builddirectory=build")
+pkg = package(preset=dhp)
+
+# if needed, define optional overrides:
+@dhp.override
+def dh_auto_install(build: Build):
+ print("dh override worked :)")
+ build.cmd("dh_auto_install --max-parallel=1")
+
+pkg.pack()
+```
+
+### Custom functions
+
+To add custom functions directly usable from CLI (like custom `debian/rules` targets for maintainers):
+
+```python
+pkg = package(...)
+
+@pkg.custom_function
+def something_custom(some_param: int, another_param: str = "some default"):
+ print(f"you passed {some_param=} {another_param=}")
+
+pkg.pack()
+```
+
+This function can be directly called with:
+
+```console
+./debian/rules.py something-custom --another-param=test 1337
+```
+
+```text
+you passed some_param=test another_param=1337
+```
+
+And generates automatic help for:
+
+```console
+./debian/rules.py something-custom --help
+```
+
+
+## Building with dpkg-buildpackage
+
+A `rules.py` package builds with plain `dpkg-buildpackage` if it also ships a `debian/rules` Makefile shim forwarding all targets to `rules.py`:
+
+```make
+#!/usr/bin/make -f
+%:
+ python3 debian/rules.py $@
+```
+
+Alternatively, build-depend on `debmagic-dpkg-driver` and let dpkg invoke `rules.py` directly - no shim needed.
+Declare both in `debian/control`:
+
+```
+Build-Driver: debmagic
+Build-Depends: debmagic-pkg, debmagic-dpkg-driver
+```
+
+`debmagic-pkg` provides the python API that `rules.py` imports, `debmagic-dpkg-driver` the perl module dpkg loads.
+`dpkg-buildpackage` (≥ 1.23.6) then loads the `Dpkg::BuildDriver::Debmagic` perl module, which dispatches the dpkg targets (`clean`, `build`, `binary`, ...) to `debian/rules.py`.
+Root handling (`Rules-Requires-Root`, fakeroot) works exactly as with `debian/rules` Makefiles.
diff --git a/docs/usage/source.md b/docs/usage/source.md
index d1907e14..1ed4e142 100644
--- a/docs/usage/source.md
+++ b/docs/usage/source.md
@@ -23,14 +23,29 @@ debmagic build source --source-dir /path/to/parent/of/debian/dir --output-dir /p
## Uploading to Launchpad
```shell
+# build, sign, then upload in one go:
debmagic build source --sign --sign-key you@example.com \
- --source-dir . --output-dir /tmp/out
-dput ppa:your-lp-username/your-ppa /tmp/out/*_source.changes
+ --upload ppa:your-lp-username/your-ppa
+
+# or upload an already-built .changes separately:
+debmagic upload ppa:your-lp-username/your-ppa ./build/pkg_*_source.changes
```
- `--sign` GPG-signs the `.dsc`/`.buildinfo`/`.changes` after building, on the host with your gpg keyring — see [Signing](build.md#signing).
- `--sign-key` picks which key/uid to sign with; omit it to fall back to the `Changed-By:`/`Maintainer:` address of the file being signed.
-- Both can be set as defaults in `debian/debmagic.toml`/`$XDG_CONFIG_HOME/debmagic/config.toml` instead of passing them every time:
+- `--upload` picks the upload target (`name` or `name:parameter`), resolved from `[upload.targets]` over the builtins (`ppa`, `ubuntu`, `debian`) — see [Uploading](upload.md). Available on binary builds too.
+- `--include-orig=auto|yes|no` controls whether the built `.changes` references the `orig` tarball (`-sa`/`-sd`): `auto` (default) includes it only when the archive cannot have it yet — a new upstream version or a deltarebase onto Debian. See [Upstream versions](upstream.md#including-the-orig-in-uploads).
+- `--changes-option` adds an extra field to the `.changes` file, passed to `dpkg-buildpackage` as-is (repeatable). This is what git-ubuntu's `prepare-upload` uses to correlate source uploads with git history:
+
+ ```shell
+ debmagic build source --sign --sign-key you@example.com \
+ --changes-option=-DVcs-Git=https://git.launchpad.net/~you/ubuntu/+source/pkg \
+ --changes-option=-DVcs-Git-Ref=refs/heads/lp12345-fix \
+ --changes-option=-DVcs-Git-Commit=36349e3669439603206cffc01dcb903b0ba2c134 \
+ --upload ppa:you/your-ppa
+ ```
+
+- Both signing settings and upload targets can be set as defaults in `debian/debmagic.toml`/`$XDG_CONFIG_HOME/debmagic/config.toml` instead of passing them every time:
```toml
[sign]
@@ -44,6 +59,9 @@ The same file selection as `debmagic build` uses to populate the build environme
Untracked-but-not-ignored files are included, so uncommitted work-in-progress changes are packaged too — useful while iterating locally.
`debian/source/options` (`tar-ignore`/`diff-ignore` patterns, etc.) is honored as usual, since it's `dpkg-source` itself that reads it.
+Debian packages also require their `orig` tarball (the "upstream" sources).
+For `3.0 (quilt)` packages the `orig` tarball is located automatically — see [Upstream versions](upstream.md#orig-tarballs-in-builds).
+
## What's NOT run
By default, `debian/rules` is never invoked (neither `dpkg-source` nor `dpkg-genchanges` need it), so this also works for source trees whose build-dependencies aren't installed anywhere.
diff --git a/docs/usage/test.md b/docs/usage/test.md
index 9297f69f..96393f17 100644
--- a/docs/usage/test.md
+++ b/docs/usage/test.md
@@ -33,6 +33,7 @@ The driver *is* the testbed: `autopkgtest` runs with the `null` backend inside t
| `--distro ` | Override the target distro for the test environment (defaults to the prior build's distro from `environment.json`, not the changelog) |
| `--proposed` | Enable the `-proposed` pocket in the test environment |
| `--apt-mirror ` | Mirror URL (same as [`debmagic build`](build.md)) |
+| `--apt-update-age ` | Configure when to run `apt-get update` (same as [`debmagic build`](build.md#apt-update-age)) |
| `--source-dir ` | Directory containing the `debian/` package directory |
| `--allow-host-test` | Allow the bare driver, which runs autopkgtest as root on the host |
| `--shell-on-failure` | On test failure, drop into an interactive shell in the test environment when stdout is a TTY |
diff --git a/docs/usage/upload.md b/docs/usage/upload.md
new file mode 100644
index 00000000..497b5f19
--- /dev/null
+++ b/docs/usage/upload.md
@@ -0,0 +1,107 @@
+# Uploading
+
+`debmagic upload` uploads a signed `.changes` file — and every file it references (`.dsc`, tarballs, `.buildinfo`) — to an archive or PPA, like `dput`.
+It always uses ssh-based transports (`scp`/`sftp`), reusing your `~/.ssh/config` (host keys, agents, proxies, users) like any other ssh tool.
+
+## TL;DR
+
+```shell
+# upload to your launchpad ppa (needs a signed .changes in the output dir):
+debmagic upload ppa:your-lp-username/your-ppa
+
+# or build, sign and upload in one go:
+debmagic build source --sign --sign-key you@example.com --upload ppa:your-lp-username/your-ppa
+
+# upload a specific .changes file to a host defined in debmagic.toml:
+debmagic upload myhost ./build/pkg_1.0-1_source.changes
+```
+
+## Upload targets
+
+The first argument is the *target*: `name` or `name:parameter` (split on the first `:`).
+
+Targets resolve in this order:
+
+1. `[upload.targets.]` in `debmagic.toml`, merged field-by-field over a same-named builtin (so a config can override just e.g. `incoming` and keep the rest)
+2. built-in targets:
+
+| name | server | incoming | login | method |
+|---|---|---|---|---|
+| `ppa` | `ppa.launchpad.net` | `~{target}/ubuntu` | `anonymous` | sftp |
+| `ubuntu` | `upload.ubuntu.com` | `ubuntu` | `anonymous` | sftp |
+| `debian` | `ssh.upload.debian.org` | `/srv/upload.debian.org/UploadQueue` | ssh config | sftp |
+
+The `:parameter` fills the `{target}` placeholder of the target's `incoming`/`server` — for `ppa:your-lp-username/your-ppa` the incoming dir becomes `~your-lp-username/your-ppa/ubuntu`.
+Both PPAs and the Ubuntu archive accept sftp uploads.
+
+A target with no builtin of that name is an error unless it's fully configured in `debmgic.toml`:
+
+```toml
+[upload.targets.myhost]
+method = "sftp" # or "scp"; unset keeps a builtin's method
+server = "example.com"
+incoming = "/srv/incoming"
+login = "sfttech" # optional; unset lets the ssh config decide
+port = 2222 # optional; unset lets the ssh config decide
+pre_upload_commands = [
+ "lintian {changes}",
+]
+```
+
+## Pre-upload checks
+
+`pre_upload_commands` run *before anything is transferred*, each via `sh -c`:
+
+- the `{changes}` placeholder is substituted with the `.changes` file path
+- `DEBMAGIC_UPLOAD_CHANGES`, `DEBMAGIC_UPLOAD_TARGET`, `DEBMAGIC_UPLOAD_TARGET_SERVER` and `DEBMAGIC_UPLOAD_TARGET_INCOMING` are set in the environment
+- a non-zero exit aborts the upload entirely
+
+This is deliberately simple: it's a list of commands, not a plugin system.
+Existing `dput-ng` hooks (which use its python "api") can be bridged by wrapping them in one command later, and debmagic's own built-in linter will simply be called from here too.
+
+Pass `--no-hooks` to skip the checks.
+
+## Including the `orig` tarball
+
+`--include-orig=auto|yes|no` decides whether the upload carries the `orig` tarball (the `-sa`/`-sd` choice, made at upload time):
+
+- `auto` (default): include only when the archive provably lacks this upstream version's orig (upstream version bump, deltarebase)
+- `yes`/`no`: always/never
+
+When the decision disagrees with the `.changes` file, its file listing is rewritten (orig entry added or removed, checksums recomputed) and it is re-signed.
+Before adding, the tarball is verified against the checksum the `.dsc` recorded — a stale orig fails loudly instead of being rejected by the archive.
+See [Upstream versions](upstream.md) for the full orig lifecycle.
+
+## Upload log and `--force`
+
+Every successful upload is recorded in a structured JSON file next to the `.changes`, e.g. `pkg_1.0-1_source.upload.json`:
+
+```json
+{
+ "uploads": [
+ { "target": "ppa:your-lp-username/your-ppa", "time": "2026-09-18T15:16:02+02:00" }
+ ]
+}
+```
+
+Uploading the same `.changes` to a target that already has a successful upload recorded is refused:
+
+```shell
+debmagic upload ppa:your-lp-username/your-ppa # "already has a successful upload ..."
+debmagic upload --force ppa:your-lp-username/your-ppa # uploads anyway
+```
+
+The check is per target spec (including the `:parameter`), so uploading the same version to several PPAs works without `--force`.
+
+## Upload methods
+
+| method | how |
+|---|---|
+| `scp` | `scp -p [-P port] :/` |
+| `sftp` | `sftp -b -` batch mode, `put`ting each file into `incoming` |
+
+Both run over ssh and read `~/.ssh/config` themselves (user, port, keys, proxies), so only explicit `login`/`port` target settings are passed on the command line.
+
+sftp paths are relative to the login's home on the server (`ubuntu` for the Ubuntu archive, `~your-lp-username/your-ppa/ubuntu` for PPAs), which is why the builtin `incoming` values above look like they do.
+
+The methods share the [`Uploader`](https://debmagic.readthedocs.io) trait; a new method (e.g. git-based uploads) is a new config value plus one implementation.
diff --git a/docs/usage/upstream.md b/docs/usage/upstream.md
new file mode 100644
index 00000000..914cca64
--- /dev/null
+++ b/docs/usage/upstream.md
@@ -0,0 +1,114 @@
+# Upstream versions
+
+`debmagic upstream` replaces `uscan`/`uupdate`: it reads the package's existing `debian/watch` and `debian/copyright` and offers two operations — checking for new upstream versions and switching the package tree to one.
+
+```shell
+debmagic upstream list # newest upstream version newer than the changelog's
+debmagic upstream list --all # every candidate, newest first
+debmagic upstream list --previous 3 # the 3 newest versions above the current one
+debmagic upstream switch latest # switch the tree to the newest version
+debmagic upstream switch 3.12.0 # switch to a specific version
+debmagic upstream switch latest --dry-run
+```
+
+## `upstream list`
+
+Queries the sources declared in `debian/watch` and prints candidate upstream versions, sorted with Debian version semantics.
+By default only versions newer than the changelog's current upstream version are shown (newest first); `--all` shows everything the watch file finds.
+
+The changelog's version is normalized with the watch file's `Dversion-Mangle` rules before comparing, so `+dfsg` suffixes and similar don't hide available updates.
+
+## `upstream switch`
+
+Fetches the chosen version's tarball, applies the package's repack configuration, writes the `orig` tarball into the package's output dir (`build/` by default) and replaces the source tree's contents — everything except `debian/`, which is kept as-is.
+The changelog is not touched; add the new version entry yourself (automated changelog handling is planned).
+
+`--dry-run` reports what would happen — tarball URL, repack excludes, version suffix — without downloading anything.
+
+For a concrete version, the tarball URL is resolved without scraping the download listing:
+
+1. the distro archives (the `launchpad` orig method) — a version Debian or Ubuntu published needs no upstream download at all
+2. the watch pattern, read as a URL template: `@ANY_VERSION@` becomes the version, `@ARCHIVE_EXT@` each archive extension in turn (the extension of the project's existing orig tarball is probed first), and the constructed URLs are probed — this also finds versions that have already fallen out of the listing
+3. the listing, fetched as a *structure teacher*: any listed sibling reveals the prefix/extension shape, and the requested version's URL is constructed from it — this covers patterns pure inversion rejects (multiple capture groups, regex constructs) and compression changes
+4. the listing, filtered for the requested version — the last resort
+
+`upstream list` always scrapes the listing, since enumeration is its job.
+
+### Signature verification
+
+After downloading, the tarball's PGP signature is verified against the keyring in `debian/upstream/signing-key.asc` — the same location on every dpkg distribution, Ubuntu included (Ubuntu packages use the same `debian/` packaging layout; there is no Ubuntu-specific path).
+Verification is on by default and uses the same OpenPGP backend as signing — `gpg` by default, `sq` (Sequoia) when `sign.tool = "sequoia"`, or your custom command when `sign.tool = "custom"`: `sign.verify_command` with `{file}`, `{signature}` and `{keyring}` placeholders (without `{signature}` the signature path is appended as the last argument), falling back to `sign.sign_command`.
+
+The signature is looked up via the watch file's `Pgp-Sig-Url-Mangle` rules; without them the common suffixes (`.asc`, `.sig`, `.sign`, `.pgp`, `.gpg`) are tried next to the tarball.
+If no signature exists upstream, the switch fails — disable the check per-run with `--no-signature-check` or persistently with `verify_signatures = false`:
+
+```toml
+[upstream]
+verify_signatures = false
+```
+
+Export the upstream key with `gpg --export --armor > debian/upstream/signing-key.asc`.
+
+### Repacking
+
+Two sources are merged:
+
+- `Files-Excluded` in `debian/copyright` — the standard way to strip non-DFSG or useless files; `Files-Excluded-` works for multiple-upstream-tarball packages
+- the watch file's `Repack`/`Repacksuffix` options, e.g. `Repacksuffix: +dfsg` appends the suffix to the upstream version of the repacked tarball
+
+The repacked `orig` tarball uses the canonical Debian layout: a single top-level `_/` directory, xz-compressed.
+
+## Watch file support
+
+`debian/watch` versions 2–5 are parsed (version 1 is rejected).
+The common options work: `Source`, `Matching-Pattern` (including the `@ANY_VERSION@`/`@ARCHIVE_EXT@`/`@PACKAGE@` substitutions), `Search-Mode`, `Uversion-Mangle`, `Dversion-Mangle`, `Filename-Mangle`, `Download-Url-Mangle`, `Pgp-Mode`/`Pgp-Sig-Url-Mangle`, `Repack`, `Repacksuffix`, `Component` (for MUT packages), `Untrackable`.
+
+http(s) sources are fetched directly; ftp sources fall back to `curl` (install it when a watch file needs it).
+
+Rare options with no debmagic equivalent yet (`Mode: git`/`svn`, `Version-Schema: group/checksum`, `Page-Mangle`, `Update-Script`, ...) produce a clear error suggesting an explicit upstream declaration instead of silently misbehaving.
+
+## Orig tarballs in builds
+
+`debmagic build source` needs the `orig` tarball for `3.0 (quilt)` packages and finds it without any configuration:
+
+1. the package's output dir (`build/` by default) — where `upstream switch` puts it
+2. next to the source tree (`../`), the conventional location — used as-is, never written to
+3. the download cache (`~/.cache/debmagic/orig///`) — populated by the `launchpad` method; a cached tarball is hardlinked into the output dir when both are on the same filesystem, copied otherwise
+4. the configured fetch method — `launchpad` by default, no configuration needed:
+
+```toml
+[orig_tarball]
+method = "launchpad" # the default: the distro archives via Launchpad's download URLs
+# or:
+method = "debian" # Debian's own archive pool, without Launchpad
+# method = "ubuntu" # Ubuntu's own archive pool, without Launchpad
+# mirrors default to the distro archives; any apt-style mirror root works:
+# debian_mirror = "https://mirror.example.com/debian"
+# ubuntu_mirror = "http://mirror.example.com/ubuntu"
+# or:
+method = "custom" # requires 'command'
+command = "uscan --download --download-current-version && cp ../foo*.orig.tar.* {output_dir}/"
+# or:
+method = "disabled" # never fetch; build only with tarballs found locally
+```
+
+The `launchpad` method constructs the orig tarball's download URL directly (`launchpad.net//+archive/primary/+files/_.orig.tar.`) and probes the compression extensions, searching Ubuntu first, then Debian (which Launchpad mirrors). No API query, configuration or authentication is needed — and no published version of the changelog head either: the orig tarball only depends on the upstream part, which older publications share.
+
+The custom command runs via `sh -c` in the source dir with `{name}`, `{version}`, `{upstream_version}`, `{source_dir}` and `{output_dir}` placeholders, and `DEBMAGIC_ORIG_OUTPUT_DIR` set.
+
+`3.0 (native)` packages skip all of this — they have no `orig` tarball.
+
+## Including the orig in uploads
+
+Whether an upload carries the `orig` tarball is decided at upload time, not build time:
+
+```shell
+debmagic build source --include-orig=auto # default
+debmagic upload ppa:you/your-ppa --include-orig=yes
+```
+
+- `auto` (default): include when the target hasn't seen this upstream version yet — no upload record, a different recorded version, or a deltarebase onto a Debian version Ubuntu never had (detected from the changelog: current revision has an `ubuntu` component, the previous entry's doesn't)
+- `yes`/`no`: always/never
+
+On `upload`, a decision that disagrees with the `.changes` file rewrites its file listing (adding or removing the `orig` entry with correct checksums) and re-signs it.
+Before adding, the tarball is verified against the checksum the `.dsc` recorded, so a stale orig next to the `.changes` fails loudly instead of being rejected by the archive.
\ No newline at end of file
diff --git a/justfile b/justfile
index 1c6e3659..dce7a4f3 100644
--- a/justfile
+++ b/justfile
@@ -44,6 +44,15 @@ ci: fmt-check lint typecheck test docs
pre-commit:
uv run pre-commit run --all-files
+# Regenerate the debmagic(1) manpage from the CLI's --help output
+manpage:
+ cargo build -p debmagic
+ help2man --no-info -N -o debian/debmagic.1 target/debug/debmagic
+
+# Build the debmagic snap
+snap:
+ snapcraft pack
+
# Build debmagic itself with the docker driver
self-build *args:
cargo run --locked -p debmagic -- build binary --driver=docker --persistent --incremental {{ args }}
diff --git a/packages/debmagic-common/Cargo.toml b/packages/debmagic-common/Cargo.toml
index ac3a451b..ecec723c 100644
--- a/packages/debmagic-common/Cargo.toml
+++ b/packages/debmagic-common/Cargo.toml
@@ -10,9 +10,15 @@ edition.workspace = true
rust-version.workspace = true
[dependencies]
+anyhow = { workspace = true }
chrono = { workspace = true }
+deb822-lossless = { workspace = true }
+debian-changelog = { workspace = true }
+md-5 = { workspace = true }
regex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+sha1 = { workspace = true }
+sha2 = { workspace = true }
[dev-dependencies]
test-case = { workspace = true }
diff --git a/packages/debmagic-common/src/changes.rs b/packages/debmagic-common/src/changes.rs
new file mode 100644
index 00000000..dbeaf940
--- /dev/null
+++ b/packages/debmagic-common/src/changes.rs
@@ -0,0 +1,234 @@
+use std::path::{Path, PathBuf};
+
+use deb822_lossless::Paragraph;
+
+use crate::debian::control::Hash;
+
+/// A parsed `.changes` file listing: which files it references and
+/// whether the `orig` tarball(s) are among them.
+#[derive(Debug, Clone, Default)]
+pub struct ChangesFiles {
+ /// All filenames listed under `Files:`/`Checksums-*:`.
+ pub files: Vec,
+}
+
+impl ChangesFiles {
+ /// The `orig` tarball entries: `_.orig.tar.`
+ /// and component origs `_.orig-.tar.`.
+ pub fn orig_tarballs(&self) -> Vec<&str> {
+ self.files
+ .iter()
+ .map(String::as_str)
+ .filter(|name| is_orig_tarball(name))
+ .collect()
+ }
+}
+
+/// Whether a filename is an `orig` tarball (main or component).
+pub fn is_orig_tarball(name: &str) -> bool {
+ // main: foo_1.0.orig.tar.gz; component: foo_1.0.orig-bar.tar.gz
+ let Some((_, rest)) = name.split_once(".orig") else {
+ return false;
+ };
+ if rest.starts_with(".tar.") {
+ return true;
+ }
+ // component: the part between `.orig-` and `.tar.` is the component name
+ match rest.strip_prefix('-').and_then(|r| r.split_once(".tar.")) {
+ Some((component, _)) => !component.is_empty() && !component.contains('/'),
+ None => false,
+ }
+}
+
+/// The `orig` tarball filename prefix for a source package version:
+/// `_.orig.tar.` — the compression extension varies.
+/// For a component, `_.orig-.tar.`.
+pub fn orig_prefix(name: &str, upstream_version: &str) -> String {
+ format!("{name}_{upstream_version}.orig.tar.")
+}
+
+/// The `orig` tarball filename prefix for a component of a source
+/// package version: `_.orig-.tar.`.
+pub fn component_orig_prefix(name: &str, upstream_version: &str, component: &str) -> String {
+ format!("{name}_{upstream_version}.orig-{component}.tar.")
+}
+
+/// Locate an existing `orig` tarball in `dir`, trying the extensions
+/// dpkg-source accepts. Component origs (`orig-`) are not
+/// matched; use `find_component_in_dir` for those.
+pub fn find_orig_in_dir(dir: &Path, name: &str, upstream_version: &str) -> Option {
+ let prefix = orig_prefix(name, upstream_version);
+ for ext in ["gz", "xz", "bz2", "lzma"] {
+ let candidate = dir.join(format!("{prefix}{ext}"));
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ }
+ None
+}
+
+/// Locate a component `orig` tarball (`_.orig-.tar.`)
+/// in `dir`.
+pub fn find_component_in_dir(
+ dir: &Path,
+ name: &str,
+ upstream_version: &str,
+ component: &str,
+) -> Option {
+ let prefix = component_orig_prefix(name, upstream_version, component);
+ for ext in ["gz", "xz", "bz2", "lzma"] {
+ let candidate = dir.join(format!("{prefix}{ext}"));
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ }
+ None
+}
+
+/// Add one `orig` tarball entry (or, with `None`, remove all orig
+/// entries) from a `.changes` paragraph's `Files:`/`Checksums-*:`
+/// listings. `tarball_data` is the content of the tarball named by
+/// `orig_name`, used for the checksum and size entries; it is unused
+/// when removing. Returns whether the paragraph was modified.
+pub fn set_changes_orig_entries(
+ control: &mut Paragraph,
+ orig_name: Option<&str>,
+ tarball_data: &[u8],
+) -> bool {
+ let mut modified = false;
+
+ let keys: Vec = control.keys().collect();
+ for key in keys {
+ if key != "Files" && !key.starts_with("Checksums-") {
+ continue;
+ }
+ let Some(value) = control.get(&key) else {
+ continue;
+ };
+ let mut lines: Vec = Vec::new();
+ for line in value.lines().filter(|l| !l.is_empty()) {
+ let name = line.split_whitespace().next_back().unwrap_or_default();
+ let is_orig = is_orig_tarball(name);
+ match orig_name {
+ // adding: keep every existing entry, origs included
+ Some(_) => lines.push(line.to_string()),
+ // removing: drop all orig entries
+ None if is_orig => modified = true,
+ None => lines.push(line.to_string()),
+ }
+ }
+ if let Some(name) = orig_name {
+ let already = lines
+ .iter()
+ .any(|l| l.split_whitespace().next_back() == Some(name));
+ if !already {
+ modified = true;
+ let hash = match key.as_str() {
+ "Files" => Hash::Md5.hex(tarball_data),
+ "Checksums-Sha1" => Hash::Sha1.hex(tarball_data),
+ "Checksums-Sha256" => Hash::Sha256.hex(tarball_data),
+ _ => continue,
+ };
+ lines.push(format!(" {} {} {}", hash, tarball_data.len(), name));
+ }
+ }
+ control.set(&key, &lines.join("\n"));
+ }
+
+ modified
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use test_case::test_case;
+
+ #[test_case("pkg_1.0.orig.tar.gz", true; "main orig")]
+ #[test_case("pkg_1.0.orig.tar.xz", true; "main orig xz")]
+ #[test_case("pkg_1.0.orig-bar.tar.xz", true; "component orig")]
+ #[test_case("pkg_1.0-1.debian.tar.xz", false; "debian tarball")]
+ #[test_case("pkg_1.0-1.dsc", false; "dsc")]
+ #[test_case("pkg_1.0.orig.tar", false; "no compression ext")]
+ fn test_is_orig_tarball(name: &str, expected: bool) {
+ assert_eq!(is_orig_tarball(name), expected);
+ }
+
+ #[test]
+ fn test_changes_files_orig_tarballs() {
+ let changes = ChangesFiles {
+ files: vec![
+ "pkg_1.0.orig.tar.xz".to_string(),
+ "pkg_1.0.orig-bar.tar.xz".to_string(),
+ "pkg_1.0-1.debian.tar.xz".to_string(),
+ "pkg_1.0-1.dsc".to_string(),
+ ],
+ };
+ assert_eq!(
+ changes.orig_tarballs(),
+ vec!["pkg_1.0.orig.tar.xz", "pkg_1.0.orig-bar.tar.xz"]
+ );
+ }
+
+ #[test]
+ fn test_find_orig_in_dir() {
+ let dir = std::env::temp_dir().join(format!("debmagic-common-orig-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ assert_eq!(find_orig_in_dir(&dir, "pkg", "1.0"), None);
+ std::fs::write(dir.join("pkg_1.0.orig.tar.xz"), "x").unwrap();
+ assert_eq!(
+ find_orig_in_dir(&dir, "pkg", "1.0"),
+ Some(dir.join("pkg_1.0.orig.tar.xz"))
+ );
+ // a component orig is not picked up by the main lookup
+ std::fs::write(dir.join("pkg_1.0.orig-bar.tar.xz"), "x").unwrap();
+ assert_eq!(
+ find_orig_in_dir(&dir, "pkg", "1.0"),
+ Some(dir.join("pkg_1.0.orig.tar.xz"))
+ );
+ assert_eq!(
+ find_component_in_dir(&dir, "pkg", "1.0", "bar"),
+ Some(dir.join("pkg_1.0.orig-bar.tar.xz"))
+ );
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn set_changes_orig_entries_adds_and_removes() {
+ let mut control: Paragraph = "Format: 1.8\nSource: pkg\nFiles:\n abc 3 pkg_1.0-1.debian.tar.xz\n def 4 pkg_1.0-1.dsc\nChecksums-Sha256:\n xyz 3 pkg_1.0-1.debian.tar.xz\n zzz 4 pkg_1.0-1.dsc\n"
+ .parse()
+ .unwrap();
+
+ // add
+ assert!(set_changes_orig_entries(
+ &mut control,
+ Some("pkg_1.0.orig.tar.xz"),
+ b"tarball"
+ ));
+ let content = control.to_string();
+ assert!(content.contains("pkg_1.0.orig.tar.xz"));
+ assert_eq!(content.matches("pkg_1.0.orig.tar.xz").count(), 2);
+
+ // adding again is a no-op
+ assert!(!set_changes_orig_entries(
+ &mut control,
+ Some("pkg_1.0.orig.tar.xz"),
+ b"tarball"
+ ));
+
+ // adding a component orig keeps the main one
+ assert!(set_changes_orig_entries(
+ &mut control,
+ Some("pkg_1.0.orig-bar.tar.xz"),
+ b"component"
+ ));
+ let content = control.to_string();
+ assert!(content.contains("pkg_1.0.orig.tar.xz"));
+ assert!(content.contains("pkg_1.0.orig-bar.tar.xz"));
+
+ // remove drops all origs, main and component
+ assert!(set_changes_orig_entries(&mut control, None, b""));
+ let content = control.to_string();
+ assert!(!content.contains("orig.tar"));
+ assert!(!content.contains("orig-bar"));
+ }
+}
diff --git a/packages/debmagic-common/src/debian.rs b/packages/debmagic-common/src/debian.rs
index a6db76ad..ad01639e 100644
--- a/packages/debmagic-common/src/debian.rs
+++ b/packages/debmagic-common/src/debian.rs
@@ -1 +1,5 @@
+pub mod changelog;
+pub mod control;
+pub mod copyright;
+pub mod source;
pub mod version;
diff --git a/packages/debmagic-common/src/debian/changelog.rs b/packages/debmagic-common/src/debian/changelog.rs
new file mode 100644
index 00000000..96f90c7b
--- /dev/null
+++ b/packages/debmagic-common/src/debian/changelog.rs
@@ -0,0 +1,162 @@
+use crate::debian::version::PackageVersion;
+
+/// The head entry of a `debian/changelog`: what identifies the
+/// package and where the upload is headed.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ChangelogHead {
+ pub package: String,
+ pub version: PackageVersion,
+ pub distributions: Vec,
+}
+
+impl ChangelogHead {
+ /// Extract the head entry of a parsed changelog.
+ pub fn from_changelog(changelog: &debian_changelog::ChangeLog) -> Option {
+ let first = changelog.iter().next()?;
+ Some(Self {
+ package: first.package()?,
+ version: PackageVersion::new(
+ first.version()?.epoch,
+ first.version()?.upstream_version,
+ first.version()?.debian_revision,
+ ),
+ distributions: first.distributions()?,
+ })
+ }
+}
+
+/// Whether a changelog's top entry looks like an Ubuntu deltarebase
+/// onto a Debian version Ubuntu has never seen: the current revision
+/// carries an `ubuntu` component while the previous entry's does not.
+/// In that case the Ubuntu archive cannot have the orig tarball yet.
+pub fn is_deltarebase_onto_debian(changelog: &debian_changelog::ChangeLog) -> bool {
+ let mut entries = changelog.iter().take(2);
+ let Some(current) = entries.next() else {
+ return false;
+ };
+ let Some(previous) = entries.next() else {
+ // a single entry says nothing about a rebase; the upload
+ // record decides whether the target needs the orig tarball
+ return false;
+ };
+ let Some(current_version) = current.version() else {
+ return false;
+ };
+ let Some(previous_version) = previous.version() else {
+ return false;
+ };
+ let current_is_ubuntu = revision_is_ubuntu(current_version.debian_revision.as_deref());
+ let previous_is_ubuntu = revision_is_ubuntu(previous_version.debian_revision.as_deref());
+ current_is_ubuntu && !previous_is_ubuntu
+}
+
+/// Whether a changelog's top entry bumps the upstream part relative
+/// to the entry below: the orig tarball of the new upstream version
+/// cannot be in the archive yet, so an upload must carry it.
+pub fn is_new_upstream_version(changelog: &debian_changelog::ChangeLog) -> bool {
+ let mut entries = changelog.iter().take(2);
+ let Some(current) = entries.next() else {
+ return false;
+ };
+ let Some(previous) = entries.next() else {
+ // a single entry says nothing about what the archive has
+ return false;
+ };
+ let (Some(current), Some(previous)) = (current.version(), previous.version()) else {
+ return false;
+ };
+ current.upstream_version != previous.upstream_version
+}
+
+/// A Debian revision contains an `ubuntu` component, e.g. `1ubuntu2`.
+fn revision_is_ubuntu(revision: Option<&str>) -> bool {
+ revision.is_some_and(|r| r.contains("ubuntu"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parse(changelog: &str) -> debian_changelog::ChangeLog {
+ changelog.parse().unwrap()
+ }
+
+ fn entry(version: &str) -> String {
+ format!(
+ "foo ({version}) unstable; urgency=medium\n\n * Some change.\n\n -- A Mon, 01 Jan 2024 00:00:00 +0000\n"
+ )
+ }
+
+ #[test]
+ fn test_deltarebase_detected() {
+ let changelog = parse(&format!(
+ "{}\n{}",
+ entry("1.2.3-1ubuntu1"),
+ entry("1.2.3-1")
+ ));
+ assert!(is_deltarebase_onto_debian(&changelog));
+ }
+
+ #[test]
+ fn test_consecutive_ubuntu_uploads_not_deltarebase() {
+ let changelog = parse(&format!(
+ "{}\n{}",
+ entry("1.2.3-1ubuntu2"),
+ entry("1.2.3-1ubuntu1")
+ ));
+ assert!(!is_deltarebase_onto_debian(&changelog));
+ }
+
+ #[test]
+ fn test_pure_debian_not_deltarebase() {
+ let changelog = parse(&format!("{}\n{}", entry("1.2.3-2"), entry("1.2.3-1")));
+ assert!(!is_deltarebase_onto_debian(&changelog));
+ }
+
+ #[test]
+ fn test_single_entry_not_deltarebase() {
+ let changelog = parse(&entry("1.2.3-1ubuntu1"));
+ assert!(!is_deltarebase_onto_debian(&changelog));
+ }
+
+ #[test]
+ fn test_new_upstream_version_detected() {
+ let changelog = parse(&format!(
+ "{}\n{}",
+ entry("1.2.3-1ubuntu1"),
+ entry("1.2.2-3ubuntu4")
+ ));
+ assert!(is_new_upstream_version(&changelog));
+ }
+
+ #[test]
+ fn test_same_upstream_not_new_upstream() {
+ let changelog = parse(&format!(
+ "{}\n{}",
+ entry("1.2.3-1ubuntu2"),
+ entry("1.2.3-1ubuntu1")
+ ));
+ assert!(!is_new_upstream_version(&changelog));
+ }
+
+ #[test]
+ fn test_new_upstream_single_entry() {
+ let changelog = parse(&entry("1.2.3-1"));
+ assert!(!is_new_upstream_version(&changelog));
+ }
+
+ #[test]
+ fn test_head_from_changelog() {
+ let changelog = parse(&entry("1.2.3-1ubuntu2"));
+ let head = ChangelogHead::from_changelog(&changelog).unwrap();
+ assert_eq!(head.package, "foo");
+ assert_eq!(head.version.version(), "1.2.3-1ubuntu2");
+ assert_eq!(head.distributions, ["unstable"]);
+ }
+
+ #[test]
+ fn test_head_from_empty_changelog() {
+ let changelog = parse("");
+ assert!(ChangelogHead::from_changelog(&changelog).is_none());
+ }
+}
diff --git a/packages/debmagic-common/src/debian/control.rs b/packages/debmagic-common/src/debian/control.rs
new file mode 100644
index 00000000..b193bb17
--- /dev/null
+++ b/packages/debmagic-common/src/debian/control.rs
@@ -0,0 +1,229 @@
+use deb822_lossless::Paragraph;
+
+/// Filenames ending in `.` listed under `Files:` or any
+/// `Checksums-*:` field.
+pub fn child_filename(paragraph: &Paragraph, ext: &str) -> Option {
+ let suffix = format!(".{ext}");
+ for key in paragraph.keys() {
+ if key != "Files" && !key.starts_with("Checksums-") {
+ continue;
+ }
+ for line in paragraph
+ .get(&key)
+ .into_iter()
+ .flat_map(|v| v.lines().map(str::to_string).collect::>())
+ {
+ if let Some(name) = line.split_whitespace().next_back()
+ && name.ends_with(&suffix)
+ {
+ return Some(name.to_string());
+ }
+ }
+ }
+ None
+}
+
+/// The hash algorithms used by the checksum fields of a control file.
+#[derive(Clone, Copy)]
+pub enum Hash {
+ Md5,
+ Sha1,
+ Sha256,
+}
+
+impl Hash {
+ pub fn hex(self, data: &[u8]) -> String {
+ fn hex(mut hasher: D, data: &[u8]) -> String {
+ hasher.update(data);
+ hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect()
+ }
+ match self {
+ Hash::Md5 => hex(md5::Md5::default(), data),
+ Hash::Sha1 => hex(sha1::Sha1::default(), data),
+ Hash::Sha256 => hex(sha2::Sha256::default(), data),
+ }
+ }
+}
+
+/// The checksum fields debmagic understands, mapped to their hash.
+pub const CHECKSUM_FIELDS: &[(&str, Hash)] = &[
+ ("Files", Hash::Md5),
+ ("Checksums-Sha1", Hash::Sha1),
+ ("Checksums-Sha256", Hash::Sha256),
+];
+
+/// Rewrite one `Files:`/`Checksums-*:` line: the first token is the
+/// checksum, the second the size, the last the filename; entries for
+/// other files pass through unchanged.
+fn rewrite_checksum_line(line: &str, filename: &str, checksum: &str, size: usize) -> String {
+ let tokens: Vec<&str> = line.split_whitespace().collect();
+ match tokens.as_slice() {
+ [old_checksum, old_size, middle @ .., name] if *name == filename => {
+ let middle = if middle.is_empty() {
+ String::new()
+ } else {
+ format!(" {}", middle.join(" "))
+ };
+ format!("{checksum} {size}{middle} {name}")
+ }
+ _ => line.to_string(),
+ }
+}
+
+/// Rewrite the size and checksum entries for the file listings in a control file
+pub fn fixup_checksums(
+ paragraph: &mut Paragraph,
+ filename: &str,
+ data: &[u8],
+) -> anyhow::Result<()> {
+ let size = data.len();
+
+ for key in paragraph.keys() {
+ if key.starts_with("Checksums-") && !CHECKSUM_FIELDS.iter().any(|(field, ..)| *field == key)
+ {
+ // An unknown checksum format would keep a stale checksum for a
+ // re-signed file, producing an upload that fails verification
+ // far away from here.
+ anyhow::bail!("unknown checksum field '{key}:' in control file");
+ }
+ }
+
+ for (key, hash) in CHECKSUM_FIELDS {
+ let Some(value) = paragraph.get(key) else {
+ continue;
+ };
+ let checksum = hash.hex(data);
+ let updated = value
+ .lines()
+ .filter(|line| !line.is_empty())
+ .map(|line| rewrite_checksum_line(line, filename, &checksum, size))
+ .collect::>()
+ .join("\n");
+ paragraph.set(key, &updated);
+ }
+ Ok(())
+}
+
+/// The digests of one file, for every algorithm a control file's
+/// checksum fields may use.
+#[derive(Debug, Clone, Default)]
+pub struct Digests {
+ pub md5: String,
+ pub sha1: String,
+ pub sha256: String,
+}
+
+impl Digests {
+ /// Digest `data` with every checksum-field algorithm at once.
+ pub fn of(data: &[u8]) -> Self {
+ Self {
+ md5: Hash::Md5.hex(data),
+ sha1: Hash::Sha1.hex(data),
+ sha256: Hash::Sha256.hex(data),
+ }
+ }
+
+ fn for_hash(&self, hash: Hash) -> &str {
+ match hash {
+ Hash::Md5 => &self.md5,
+ Hash::Sha1 => &self.sha1,
+ Hash::Sha256 => &self.sha256,
+ }
+ }
+}
+
+/// Verify that the checksum entries a paragraph records for `filename`
+/// match `digests`. Returns whether any entry was checked; a paragraph
+/// listing no entry for the file is not an error.
+pub fn verify_checksums(
+ paragraph: &Paragraph,
+ filename: &str,
+ digests: &Digests,
+) -> anyhow::Result {
+ let mut checked = false;
+ for (field, hash) in CHECKSUM_FIELDS {
+ let Some(value) = paragraph.get(field) else {
+ continue;
+ };
+ let actual = digests.for_hash(*hash);
+ for line in value.lines().filter(|l| !l.is_empty()) {
+ let tokens: Vec<&str> = line.split_whitespace().collect();
+ if tokens.last() != Some(&filename) {
+ continue;
+ }
+ let Some(&expected) = tokens.first() else {
+ continue;
+ };
+ checked = true;
+ if actual != expected {
+ anyhow::bail!("{filename} does not match the checksum {field} recorded for it");
+ }
+ }
+ }
+ Ok(checked)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn verify_checksums_matches_and_mismatches() {
+ let data = b"tarball content";
+ let digests = Digests::of(data);
+ let control: Paragraph = format!(
+ "Format: 3.0 (quilt)\nFiles:\n {} 15 debiandev optional pkg_1.0.orig.tar.xz\nChecksums-Sha256:\n {} 15 pkg_1.0.orig.tar.xz\n",
+ digests.md5, digests.sha256
+ )
+ .parse()
+ .unwrap();
+
+ assert!(verify_checksums(&control, "pkg_1.0.orig.tar.xz", &digests).unwrap());
+
+ // a stale tarball fails
+ let stale = Digests::of(b"different content");
+ assert!(verify_checksums(&control, "pkg_1.0.orig.tar.xz", &stale).is_err());
+
+ // a file the paragraph does not list is not checked
+ assert!(!verify_checksums(&control, "other.tar.xz", &digests).unwrap());
+ }
+
+ fn parse_control(content: &str) -> Paragraph {
+ content.parse().unwrap()
+ }
+
+ #[test]
+ fn child_filename_finds_dsc_and_buildinfo() {
+ let control = parse_control(
+ "Format: 1.8\nSource: pkg\nFiles:\n abc 123 pkg_1.0.dsc\n def 456 pkg_1.0.buildinfo\n ghi 789 other.txt\nChecksums-Sha256:\n xyz 123 pkg_1.0.dsc\n",
+ );
+ assert_eq!(
+ child_filename(&control, "dsc").as_deref(),
+ Some("pkg_1.0.dsc")
+ );
+ assert_eq!(
+ child_filename(&control, "buildinfo").as_deref(),
+ Some("pkg_1.0.buildinfo")
+ );
+ assert_eq!(child_filename(&control, "deb"), None);
+ }
+
+ #[test]
+ fn fixup_rewrites_all_checksum_sections() {
+ let mut control = parse_control(
+ "Format: 1.8\nFiles:\n oldmd5 3 hash optional pkg_1.0.dsc\nChecksums-Sha1:\n oldsha1 3 pkg_1.0.dsc\nChecksums-Sha256:\n oldsha256 3 pkg_1.0.dsc\n",
+ );
+ let data = b"abc";
+ fixup_checksums(&mut control, "pkg_1.0.dsc", data).unwrap();
+
+ let md5 = Hash::Md5.hex(data);
+ let joined = control.to_string();
+ assert!(joined.contains(&format!("{md5} 3 hash optional pkg_1.0.dsc")));
+ assert!(joined.contains(&format!(" {} 3 pkg_1.0.dsc", Hash::Sha1.hex(data))));
+ assert!(joined.contains(&format!(" {} 3 pkg_1.0.dsc", Hash::Sha256.hex(data))));
+ }
+}
diff --git a/packages/debmagic-common/src/debian/copyright.rs b/packages/debmagic-common/src/debian/copyright.rs
new file mode 100644
index 00000000..53a6168b
--- /dev/null
+++ b/packages/debmagic-common/src/debian/copyright.rs
@@ -0,0 +1,99 @@
+/// The `Files-Excluded` patterns from a `debian/copyright` file.
+///
+/// The field is a machine-readable header (not part of the deb822
+/// paragraphs): `Files-Excluded:` for the main tree and
+/// `Files-Excluded-:` for a multiple-upstream-tarballs
+/// component, each with indented or same-line glob patterns.
+#[derive(Debug, Clone, Default)]
+pub struct FilesExcluded {
+ /// Patterns of the main `Files-Excluded:` field.
+ pub main: Vec,
+ /// Patterns per component, keyed by component name.
+ pub components: std::collections::HashMap>,
+}
+
+/// Parse the `Files-Excluded` fields from a `debian/copyright`
+/// file's content.
+pub fn files_excluded(content: &str) -> FilesExcluded {
+ let mut result = FilesExcluded::default();
+ // the component whose patterns we are collecting; `None` = main
+ let mut current: Option