Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions crates/formats/apt/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
/// Lowercase hex digests.
Expand Down Expand Up @@ -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::<Vec<_>>()
.join("/")
}

/// A lowercase hex digest of `len` digits, if `value` is one.
fn hex_digest(value: &str, len: usize) -> Option<String> {
(value.len() == len && value.bytes().all(|b| b.is_ascii_hexdigit()))
Expand Down Expand Up @@ -422,7 +435,7 @@ pub fn read_packages(index: impl Read) -> Result<Vec<Entry>, 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),
Expand Down Expand Up @@ -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 [
Expand Down
15 changes: 8 additions & 7 deletions crates/server/tests/apt_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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"]);
Expand Down
5 changes: 5 additions & 0 deletions docs/changes/2.2.0/754.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading