pull: add --repository to pull from external flat repositories

Add a --repository flag taking the full suite URL of an external flat
repository (e.g. https://pkg.noctalia.dev/deb/resolute/), i.e. one with
no dists/ hierarchy, like apt's exact-path suites ('Suites: resolute/').

The suite name is read from the root Release file (Codename/Suite), the
sources index is fetched from the repository root as Sources.xz/gz/plain,
and package files are resolved against the URL root, ignoring the stanza
Directory field like apt does. As with PPAs, the stanza Vcs-Git is never
used for external repositories, so the source always comes from the
repository itself.

Also make the sources index parser detect compression by magic bytes
(gz/xz/plain) instead of assuming gzip, and fix extraction of archives
with './'-prefixed entries, which previously aborted and are now
extracted in place instead of being relocated.
This commit is contained in:
2026-09-15 10:57:06 +02:00
parent 3b99ece39a
commit 5500f98586
5 changed files with 256 additions and 49 deletions
+79 -44
View File
@@ -153,13 +153,23 @@ fn copy_file_times(src: &Path, dest: &Path) -> Result<(), Box<dyn Error>> {
Ok(())
}
/// Result of extracting an archive
struct ExtractedArchive {
/// Paths of the extracted files
files: Vec<String>,
/// The archive used './'-prefixed entries: its contents were extracted
/// directly into the destination directory, and there is no
/// 'package-version/' top-level directory to relocate
in_place: bool,
}
/// Helper function to extract tar archive with progress tracking
fn extract_tar_archive<D, F>(
file_path: &Path,
dest: &Path,
progress: ProgressCallback<'_>,
decoder_factory: F,
) -> Result<Vec<String>, Box<dyn Error>>
) -> Result<ExtractedArchive, Box<dyn Error>>
where
D: std::io::Read,
F: Fn(File) -> D,
@@ -178,11 +188,28 @@ where
let mut archive = Archive::new(decoder);
let mut extracted_files = Vec::new();
let mut in_place = false;
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let dest_path = dest.join(&path);
// Archives built with './'-prefixed entries (common in third-party
// repositories) target the destination directory itself, with no
// 'package-version/' wrapper; skip their root entry, and remember
// the layout for the caller
let relative = if path.to_string_lossy().starts_with("./") {
in_place = true;
path.strip_prefix("./").unwrap_or(&path).to_path_buf()
} else {
path
};
if relative.as_os_str().is_empty() {
continue;
}
let dest_path = dest.join(&relative);
// Create parent directories if needed
if let Some(parent) = dest_path.parent() {
@@ -201,14 +228,17 @@ where
}
}
Ok(extracted_files)
Ok(ExtractedArchive {
files: extracted_files,
in_place,
})
}
fn extract_archive(
path: &Path,
dest: &Path,
progress: ProgressCallback<'_>,
) -> Result<Vec<String>, Box<dyn Error>> {
) -> Result<ExtractedArchive, Box<dyn Error>> {
let filename = path.file_name().unwrap().to_string_lossy();
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
@@ -465,47 +495,52 @@ async fn fetch_archive_sources(
continue;
}
// List root directories extracted and use the first one as the source directory
debug!("Root directories extracted:");
let mut source_dir: Option<PathBuf> = None;
for file in &extracted {
let path = Path::new(file);
// Check if this is a directory and is at the archive root level
// (i.e., the path relative to extract_dir has no parent components)
if let Ok(relative_path) = path.strip_prefix(&extract_dir)
&& relative_path.components().count() == 1
&& path.is_dir()
{
debug!("- {}", relative_path.file_name().unwrap().to_string_lossy());
// Use the first directory found as the source
if source_dir.is_none() {
source_dir = Some(path.to_path_buf());
}
}
}
// Use the extracted directory as the source, assuming there is only one
if let Some(src_dir) = source_dir {
let target_dir = package_dir.join(&info.stanza.package);
if target_dir.exists() {
// Target exists, we need to merge contents
for sub_entry in std::fs::read_dir(&src_dir)? {
let sub_entry = sub_entry?;
let sub_path = sub_entry.path();
let target_path = target_dir.join(sub_entry.file_name());
if sub_path.is_dir() {
std::fs::create_dir_all(&target_path)?;
// Recursively copy directory contents
copy_dir_all(&sub_path, &target_path)?;
} else {
std::fs::copy(&sub_path, &target_path)?;
copy_file_times(&sub_path, &target_path)?;
// Archives with './'-prefixed entries are already laid out
// directly in the package directory; only 'package-version/'
// style ones need their contents relocated
if !extracted.in_place {
// List root directories extracted and use the first one as the source directory
debug!("Root directories extracted:");
let mut source_dir: Option<PathBuf> = None;
for file in &extracted.files {
let path = Path::new(file);
// Check if this is a directory and is at the archive root level
// (i.e., the path relative to extract_dir has no parent components)
if let Ok(relative_path) = path.strip_prefix(&extract_dir)
&& relative_path.components().count() == 1
&& path.is_dir()
{
debug!("- {}", relative_path.file_name().unwrap().to_string_lossy());
// Use the first directory found as the source
if source_dir.is_none() {
source_dir = Some(path.to_path_buf());
}
}
std::fs::remove_dir_all(&src_dir)?;
} else {
std::fs::rename(&src_dir, &target_dir)?;
}
// Use the extracted directory as the source, assuming there is only one
if let Some(src_dir) = source_dir {
let target_dir = package_dir.join(&info.stanza.package);
if target_dir.exists() {
// Target exists, we need to merge contents
for sub_entry in std::fs::read_dir(&src_dir)? {
let sub_entry = sub_entry?;
let sub_path = sub_entry.path();
let target_path = target_dir.join(sub_entry.file_name());
if sub_path.is_dir() {
std::fs::create_dir_all(&target_path)?;
// Recursively copy directory contents
copy_dir_all(&sub_path, &target_path)?;
} else {
std::fs::copy(&sub_path, &target_path)?;
copy_file_times(&sub_path, &target_path)?;
}
}
std::fs::remove_dir_all(&src_dir)?;
} else {
std::fs::rename(&src_dir, &target_dir)?;
}
}
}
}
@@ -679,7 +714,7 @@ mod tests {
let cwd = temp_dir.path();
// Main 'pull' command: the one we want to test
let info = crate::package_info::lookup(package, None, series, "", dist, None, None)
let info = crate::package_info::lookup(package, None, series, "", dist, None, None, None)
.await
.unwrap();
pull(&info, Some(cwd), None, archive.unwrap_or(false))