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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Security
- Package filesystem operations now enforce the manifest's package-name rules,
reject symlinked cache/install roots and targets, verify canonical directory
containment before recursive deletion, and prevent archive extraction through
pre-existing symlink ancestors.
- **WFL package publishing now keeps credentials registry-scoped.** A
project-controlled `registry` setting can no longer redirect a saved token to
another origin; registry URLs are canonicalized and must use HTTPS without
Expand Down
13 changes: 7 additions & 6 deletions crates/wflpkg/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,15 @@ pub fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<(), Packa
)));
}

// Create parent directories as needed
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}

entry.unpack(&target).map_err(|e| {
let unpacked = entry.unpack_in(&dest_canonical).map_err(|e| {
PackageError::General(format!("Failed to extract {}: {}", entry_path.display(), e))
})?;
if !unpacked {
return Err(PackageError::General(format!(
"Archive entry escapes destination: {}",
entry_path.display()
)));
}
}

Ok(())
Expand Down
255 changes: 229 additions & 26 deletions crates/wflpkg/src/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,103 @@
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use crate::error::PackageError;
use crate::manifest::parser::validate_package_name;
use crate::manifest::version::Version;

fn verify_directory(path: &Path, description: &str) -> Result<PathBuf, PackageError> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(PackageError::General(format!(
"Refusing to use {} \"{}\": symbolic links are not allowed.",
description,
path.display()
)));
}
if !metadata.is_dir() {
return Err(PackageError::General(format!(
"Refusing to use {} \"{}\": it is not a directory.",
description,
path.display()
)));
}
path.canonicalize().map_err(|error| {
PackageError::General(format!(
"Could not verify {} \"{}\": {}",
description,
path.display(),
error
))
})
}

fn existing_child_directory(
parent: &Path,
child: &Path,
description: &str,
) -> Result<Option<PathBuf>, PackageError> {
let metadata = match std::fs::symlink_metadata(child) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() {
return Err(PackageError::General(format!(
"Refusing to use {} \"{}\": symbolic links are not allowed.",
description,
child.display()
)));
}
if !metadata.is_dir() {
return Err(PackageError::General(format!(
"Refusing to use {} \"{}\": it is not a directory.",
description,
child.display()
)));
}

let canonical_child = child.canonicalize().map_err(|error| {
PackageError::General(format!(
"Could not verify {} \"{}\": {}",
description,
child.display(),
error
))
})?;
if canonical_child.parent() != Some(parent) {
return Err(PackageError::General(format!(
"Refusing to use {} \"{}\": it escapes its expected parent directory.",
description,
child.display()
)));
}

Ok(Some(canonical_child))
}

fn ensure_child_directory(
parent: &Path,
child: &Path,
description: &str,
) -> Result<PathBuf, PackageError> {
if let Some(existing) = existing_child_directory(parent, child, description)? {
return Ok(existing);
}

match std::fs::create_dir(child) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => return Err(error.into()),
}
existing_child_directory(parent, child, description)?.ok_or_else(|| {
PackageError::General(format!(
"Could not create {} \"{}\".",
description,
child.display()
))
})
}

/// Manages the global package cache at `~/.wfl/packages/`.
pub struct PackageCache {
cache_dir: PathBuf,
Expand All @@ -13,33 +108,81 @@ impl PackageCache {
pub fn new() -> Result<Self, PackageError> {
let home = dirs_home()?;
let cache_dir = home.join(".wfl").join("packages");
std::fs::create_dir_all(&cache_dir)?;
Ok(Self { cache_dir })
Self::with_dir(cache_dir)
}

/// Create a cache manager with a custom directory (for testing).
pub fn with_dir(cache_dir: PathBuf) -> Result<Self, PackageError> {
std::fs::create_dir_all(&cache_dir)?;
Ok(Self { cache_dir })
match std::fs::symlink_metadata(&cache_dir) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(PackageError::General(format!(
"Refusing to use package cache \"{}\": symbolic links are not allowed.",
cache_dir.display()
)));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(PackageError::General(format!(
"Refusing to use package cache \"{}\": it is not a directory.",
cache_dir.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {
std::fs::create_dir_all(&cache_dir)?;
}
Err(error) => return Err(error.into()),
}

Ok(Self {
cache_dir: verify_directory(&cache_dir, "package cache")?,
})
}

/// Get the path for a specific package version in the cache.
pub fn package_path(&self, name: &str, version: &Version) -> PathBuf {
if validate_package_name(name).is_err() {
return self
.cache_dir
.join(".invalid-package-name")
.join(version.to_string());
}
self.cache_dir.join(name).join(version.to_string())
}

/// Check if a package version is cached.
pub fn is_cached(&self, name: &str, version: &Version) -> bool {
self.package_path(name, version).exists()
if validate_package_name(name).is_err() {
return false;
}
self.cached_package_directory(name, version)
.ok()
.flatten()
.is_some()
}

/// Store a package in the cache by copying from a source directory.
pub fn store(&self, name: &str, version: &Version, source: &Path) -> Result<(), PackageError> {
let dest = self.package_path(name, version);
if dest.exists() {
std::fs::remove_dir_all(&dest)?;
validate_package_name(name)?;
let source = verify_directory(source, "package source")?;
let cache_root = self.verified_cache_root()?;
let package_root = ensure_child_directory(
&cache_root,
&cache_root.join(name),
"package cache directory",
)?;
let dest = package_root.join(version.to_string());
if let Some(existing) =
existing_child_directory(&package_root, &dest, "cached package version")?
{
if existing == source {
return Err(PackageError::General(
"Refusing to replace a cached package with itself.".to_string(),
));
}
std::fs::remove_dir_all(existing)?;
}
copy_dir_recursive(source, &dest)?;

copy_dir_recursive(&source, &dest)?;
Ok(())
}

Expand All @@ -50,34 +193,57 @@ impl PackageCache {
version: &Version,
project_dir: &Path,
) -> Result<(), PackageError> {
let cache_path = self.package_path(name, version);
if !cache_path.exists() {
return Err(PackageError::General(format!(
"Package {} {} is not in the cache.",
name, version
)));
}
validate_package_name(name)?;
let cache_path = self
.cached_package_directory(name, version)?
.ok_or_else(|| {
PackageError::General(format!("Package {} {} is not in the cache.", name, version))
})?;

let dest = project_dir.join("packages").join(name);
if dest.exists() {
std::fs::remove_dir_all(&dest)?;
let project_root = project_dir.canonicalize().map_err(|error| {
PackageError::General(format!(
"Could not verify project directory \"{}\": {}",
project_dir.display(),
error
))
})?;
let packages_path = project_root.join("packages");
let packages_root =
ensure_child_directory(&project_root, &packages_path, "project packages directory")?;
let dest = packages_root.join(name);
if let Some(existing) =
existing_child_directory(&packages_root, &dest, "installed package directory")?
{
std::fs::remove_dir_all(existing)?;
}
std::fs::create_dir_all(&dest)?;
copy_dir_recursive(&cache_path, &dest)?;
Ok(())
}

/// List all cached versions of a package.
pub fn list_versions(&self, name: &str) -> Result<Vec<Version>, PackageError> {
let pkg_dir = self.cache_dir.join(name);
if !pkg_dir.exists() {
return Ok(Vec::new());
}
validate_package_name(name)?;
let cache_root = self.verified_cache_root()?;
let pkg_dir = match existing_child_directory(
&cache_root,
&cache_root.join(name),
"package cache directory",
)? {
Some(path) => path,
None => return Ok(Vec::new()),
};

let mut versions = Vec::new();
for entry in std::fs::read_dir(&pkg_dir)? {
let entry = entry?;
if entry.path().is_dir()
let file_type = entry.file_type()?;
if file_type.is_symlink() {
return Err(PackageError::General(format!(
"Refusing to inspect cached package version \"{}\": symbolic links are not allowed.",
entry.path().display()
)));
}
if file_type.is_dir()
&& let Ok(v) = Version::parse(&entry.file_name().to_string_lossy())
{
versions.push(v);
Expand All @@ -91,6 +257,38 @@ impl PackageCache {
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}

fn verified_cache_root(&self) -> Result<PathBuf, PackageError> {
let canonical = verify_directory(&self.cache_dir, "package cache")?;
if canonical != self.cache_dir {
return Err(PackageError::General(format!(
"Refusing to use package cache \"{}\": its filesystem location changed.",
self.cache_dir.display()
)));
}
Ok(canonical)
}

fn cached_package_directory(
&self,
name: &str,
version: &Version,
) -> Result<Option<PathBuf>, PackageError> {
let cache_root = self.verified_cache_root()?;
let package_root = match existing_child_directory(
&cache_root,
&cache_root.join(name),
"package cache directory",
)? {
Some(path) => path,
None => return Ok(None),
};
existing_child_directory(
&package_root,
&package_root.join(version.to_string()),
"cached package version",
)
}
}

/// Recursively copy a directory.
Expand All @@ -112,8 +310,13 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {

if metadata.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
} else if metadata.is_file() {
std::fs::copy(&src_path, &dst_path)?;
Comment on lines 311 to 314
} else {
return Err(PackageError::General(format!(
"Unsupported filesystem entry found in package: {}",
src_path.display()
)));
}
}
Ok(())
Expand Down
Loading
Loading