From d4feadd8b082eafe0e6f9e9a35c6cf21e1f25dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:37:16 +0000 Subject: [PATCH] Resolve a leading ./ in an apt import's Filename, as apt does Indexes made with `dpkg-scanpackages ./pool` or `apt-ftparchive packages ./pool` list `Filename: ./pool/...`, which `files::validate_path` refuses, so an import of such a repository failed every package. The parser now drops a Filename's `.` segments (resolve_filename), so the check, the download and the item's stored Filename all see the resolved path; `..`, empty segments and a leading `/` stay and are still refused. Fixes #754 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJaER1FpBfS1RFru5JP --- crates/formats/apt/src/import.rs | 54 +++++++++++++++++++++++++++++-- crates/server/tests/apt_import.rs | 15 +++++---- docs/changes/2.2.0/754.fixed.md | 5 +++ 3 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 docs/changes/2.2.0/754.fixed.md diff --git a/crates/formats/apt/src/import.rs b/crates/formats/apt/src/import.rs index 69b9d1bd..f6317d4a 100644 --- a/crates/formats/apt/src/import.rs +++ b/crates/formats/apt/src/import.rs @@ -283,7 +283,8 @@ pub struct Entry { pub package: String, pub version: String, pub architecture: String, - /// Where the package is, below the repository's base URL. + /// Where the package is, below the repository's base URL, its `.` + /// segments dropped ([`resolve_filename`]). pub filename: String, pub size: Option, /// Lowercase hex digests. @@ -339,6 +340,18 @@ impl Entry { } } +/// A `Filename` as apt resolves it against the base URL: its `.` segments +/// (the leading `./` of `dpkg-scanpackages ./pool`) dropped. Everything else +/// stays, a `..`, an empty segment or a leading `/` among it, for +/// [`Entry::problem`] to refuse. +pub fn resolve_filename(filename: &str) -> String { + filename + .split('/') + .filter(|segment| *segment != ".") + .collect::>() + .join("/") +} + /// A lowercase hex digest of `len` digits, if `value` is one. fn hex_digest(value: &str, len: usize) -> Option { (value.len() == len && value.bytes().all(|b| b.is_ascii_hexdigit())) @@ -422,7 +435,7 @@ pub fn read_packages(index: impl Read) -> Result, String> { } "version" => entry.version = value.to_string(), "architecture" => entry.architecture = value.to_string(), - "filename" => entry.filename = value.to_string(), + "filename" => entry.filename = resolve_filename(value), "size" => entry.size = value.parse().ok(), "sha256" => entry.sha256 = hex_digest(value, 64), "sha512" => entry.sha512 = hex_digest(value, 128), @@ -685,6 +698,43 @@ mod tests { assert!(component_problem("updates/main").is_some()); } + #[test] + fn a_filename_is_resolved_as_apt_resolves_it() { + // `dpkg-scanpackages ./pool` and `apt-ftparchive packages ./pool` + // write a leading `./`. + let index = b"Package: hello\nVersion: 1.0\nArchitecture: amd64\n\ + Filename: ./pool/main/h/hello/./hello_1.0_amd64.deb\nSize: 42\n\ + SHA256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n"; + let entries = read_packages(&index[..]).unwrap(); + assert_eq!(entries[0].filename, "pool/main/h/hello/hello_1.0_amd64.deb"); + assert_eq!(entries[0].problem(), None); + for (listed, resolved) in [ + ("pool/x.deb", "pool/x.deb"), + ("./pool/x.deb", "pool/x.deb"), + ("././pool/./x.deb", "pool/x.deb"), + // A `..`, an empty segment and a leading `/` stay, refused. + ("./../x.deb", "../x.deb"), + ("pool/../../x.deb", "pool/../../x.deb"), + ("pool//x.deb", "pool//x.deb"), + ("/./pool/x.deb", "/pool/x.deb"), + ] { + assert_eq!(resolve_filename(listed), resolved, "{listed}"); + } + for refused in ["./../x.deb", "pool/./../x.deb", "/./pool/x.deb", "./"] { + let entry = read_packages( + format!( + "Package: a\nVersion: 1\nArchitecture: all\nFilename: {refused}\n\ + Size: 1\nSHA256: {}\n", + "0".repeat(64) + ) + .as_bytes(), + ) + .unwrap() + .remove(0); + assert!(entry.problem().is_some(), "{refused}"); + } + } + #[test] fn a_malformed_index_is_refused() { for bad in [ diff --git a/crates/server/tests/apt_import.rs b/crates/server/tests/apt_import.rs index 9dfd0903..a7a2b792 100644 --- a/crates/server/tests/apt_import.rs +++ b/crates/server/tests/apt_import.rs @@ -13,7 +13,8 @@ //! it in, whatever its `Section` says; a `Packages` entry whose size or //! SHA-256 does not match its `.deb` fails that item, as do one the //! source does not have and one whose `Filename` is outside the -//! repository, and the import completes; an index that does not match +//! repository, and the import completes; a `Filename` that starts with +//! `./` (`dpkg-scanpackages ./pool`) is resolved below the repository; an index that does not match //! `Release` fails the scan; a version Forge holds with other bytes //! follows `on_conflict`, decided without a download; a dry run writes //! nothing; @@ -554,15 +555,13 @@ async fn apt_import_from_a_static_repository_checks_every_package_and_index() { ), ], ); + // `fine`'s Filename starts with `./`, as `dpkg-scanpackages ./pool` + // writes it: apt resolves it below the repository. + let dotted = format!("./{}", pool["fine"]); mirror.index( "main", "arm64", - &[stanza( - &fine_control, - pool["fine"], - fine.len(), - &sha256(&fine), - )], + &[stanza(&fine_control, &dotted, fine.len(), &sha256(&fine))], ); mirror.release(Some(&key)); @@ -601,6 +600,8 @@ async fn apt_import_from_a_static_repository_checks_every_package_and_index() { "{found:?}" ); assert_eq!(item(&found, "fine:arm64")["version"], "1:2.0-1"); + assert_eq!(item(&found, "fine:arm64")["state"], "imported", "{found:?}"); + assert_eq!(mirror.gets(pool["fine"]), 1); // `moved` stays in main, where the source lists it. assert_eq!(listed(&srv, &dst, "main", "amd64").await, ["moved"]); diff --git a/docs/changes/2.2.0/754.fixed.md b/docs/changes/2.2.0/754.fixed.md new file mode 100644 index 00000000..4381ec05 --- /dev/null +++ b/docs/changes/2.2.0/754.fixed.md @@ -0,0 +1,5 @@ +An import of an apt repository made with `dpkg-scanpackages ./pool` or +`apt-ftparchive packages ./pool` failed every package with "is no path below +the repository", because their index entries' `Filename` starts with `./`. +The import now drops a `Filename`'s `.` segments before it checks and +downloads the package, as apt does; a `..` is still refused.