Skip to content

Commit

Permalink
Apt - Minimum Viable Buildpack - 02 - Aptfile Parsing (#2)
Browse files Browse the repository at this point in the history
The `Aptfile` is how an application can specify custom packages to install. Unlike the version from our [classic apt buildpack](https://github.com/heroku/heroku-buildpack-apt), this parser **does not support**:
- [Custom debian packages](https://github.com/heroku/heroku-buildpack-apt/blob/master/bin/compile#L84-L89)
- [Custom repositories](https://github.com/heroku/heroku-buildpack-apt/blob/master/bin/compile#L71-L72)

Only package names, comment lines, and blank lines are supported in this version. 

Package names are parsed according to the restrictions defined in the [Debian Manual](https://www.debian.org/doc/debian-policy/ch-controlfields.html#source).
  • Loading branch information
colincasey authored Feb 21, 2024
1 parent 89e9e34 commit 6c91816
Show file tree
Hide file tree
Showing 8 changed files with 879 additions and 32 deletions.
690 changes: 665 additions & 25 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ edition = "2021"
rust-version = "1.76"

[dependencies]
commons = { git = "https://github.com/heroku/buildpacks-ruby", branch = "main" }
libcnb = "=0.18.0"

[dev-dependencies]
libcnb-test = "=0.18.0"
indoc = "2"

[lints.rust]
unreachable_pub = "warn"
Expand Down
136 changes: 136 additions & 0 deletions src/aptfile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::collections::HashSet;
use std::str::FromStr;

#[derive(Debug, Eq, PartialEq)]
pub(crate) struct Aptfile {
packages: HashSet<DebianPackageName>,
}

impl FromStr for Aptfile {
type Err = ParseAptfileError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
value
.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#') && !line.is_empty())
.map(DebianPackageName::from_str)
.collect::<Result<HashSet<_>, _>>()
.map_err(ParseAptfileError)
.map(|packages| Aptfile { packages })
}
}

#[derive(Debug, PartialEq)]
pub(crate) struct ParseAptfileError(ParseDebianPackageNameError);

#[derive(Debug, Eq, PartialEq, Hash)]
pub(crate) struct DebianPackageName(String);

impl FromStr for DebianPackageName {
type Err = ParseDebianPackageNameError;

fn from_str(value: &str) -> Result<Self, Self::Err> {
// https://www.debian.org/doc/debian-policy/ch-controlfields.html#source
// Package names (both source and binary, see Package) must consist only of
// lower case letters (a-z), digits (0-9), plus (+) and minus (-) signs,
// and periods (.). They must be at least two characters long and must
// start with an alphanumeric character.
let is_valid_package_name = value
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '+' | '-' | '.'))
&& value.chars().count() >= 2
&& value.starts_with(|c: char| c.is_ascii_alphanumeric());

if is_valid_package_name {
Ok(DebianPackageName(value.to_string()))
} else {
Err(ParseDebianPackageNameError(value.to_string()))
}
}
}

#[derive(Debug, PartialEq)]
pub(crate) struct ParseDebianPackageNameError(String);

#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;

#[test]
fn parse_valid_debian_package_name() {
let valid_names = [
"a0", // min length, starting with number
"0a", // min length, starting with letter
"g++", // alphanumeric to start followed by non-alphanumeric characters
"libevent-2.1-6", // just a mix of allowed characters
"a0+.-", // all the allowed characters
];
for valid_name in valid_names {
assert_eq!(
DebianPackageName::from_str(valid_name).unwrap(),
DebianPackageName(valid_name.to_string())
);
}
}

#[test]
fn parse_invalid_debian_package_name() {
let invalid_names = [
"a", // too short
"+a", // can't start with non-alphanumeric character
"ab_c", // can't contain invalid characters
"aBc", // uppercase is not allowed
"package=1.2.3-1", // versioning is not allowed, package name only
];
for invalid_name in invalid_names {
assert_eq!(
DebianPackageName::from_str(invalid_name).unwrap_err(),
ParseDebianPackageNameError(invalid_name.to_string())
);
}
}

#[test]
fn parse_aptfile() {
let aptfile = Aptfile::from_str(indoc! { "
# comment line
# comment line with leading whitespace
package-name-1
package-name-2
# Package name has leading and trailing whitespace
package-name-3 \t
# Duplicates are allowed (at least for now)
package-name-1
" })
.unwrap();
assert_eq!(
aptfile,
Aptfile {
packages: HashSet::from([
DebianPackageName("package-name-1".to_string()),
DebianPackageName("package-name-2".to_string()),
DebianPackageName("package-name-3".to_string()),
])
}
);
}

#[test]
fn parse_invalid_aptfile() {
let error = Aptfile::from_str(indoc! { "
invalid package name!
" })
.unwrap_err();
assert_eq!(
error,
ParseAptfileError(ParseDebianPackageNameError(
"invalid package name!".to_string()
))
);
}
}
15 changes: 14 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
use crate::aptfile::ParseAptfileError;

#[derive(Debug)]
pub(crate) enum AptBuildpackError {}
#[allow(clippy::enum_variant_names)]
pub(crate) enum AptBuildpackError {
DetectAptfile(std::io::Error),
ReadAptfile(std::io::Error),
ParseAptfile(ParseAptfileError),
}

impl From<AptBuildpackError> for libcnb::Error<AptBuildpackError> {
fn from(value: AptBuildpackError) -> Self {
Self::BuildpackError(value)
}
}
38 changes: 32 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,28 +1,54 @@
use crate::aptfile::Aptfile;
use crate::errors::AptBuildpackError;
use libcnb::build::{BuildContext, BuildResult};
use libcnb::detect::{DetectContext, DetectResult};
use commons::output::build_log::{BuildLog, Logger};
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder};
use libcnb::generic::{GenericMetadata, GenericPlatform};
use libcnb::{buildpack_main, Buildpack};
use std::fs;
use std::io::stdout;

#[cfg(test)]
use libcnb_test as _;

mod aptfile;
mod errors;

buildpack_main!(AptBuildpack);

const APTFILE_PATH: &str = "Aptfile";

struct AptBuildpack;

impl Buildpack for AptBuildpack {
type Platform = GenericPlatform;
type Metadata = GenericMetadata;
type Error = AptBuildpackError;

fn detect(&self, _context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
todo!()
fn detect(&self, context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
let aptfile_exists = context
.app_dir
.join(APTFILE_PATH)
.try_exists()
.map_err(AptBuildpackError::DetectAptfile)?;

if aptfile_exists {
DetectResultBuilder::pass().build()
} else {
BuildLog::new(stdout())
.without_buildpack_name()
.announce()
.warning("No Aptfile found.");
DetectResultBuilder::fail().build()
}
}

fn build(&self, _context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
todo!()
fn build(&self, context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
let _aptfile: Aptfile = fs::read_to_string(context.app_dir.join(APTFILE_PATH))
.map_err(AptBuildpackError::ReadAptfile)?
.parse()
.map_err(AptBuildpackError::ParseAptfile)?;

BuildResultBuilder::new().build()
}
}
Empty file added tests/fixtures/basic/Aptfile
Empty file.
Empty file.
30 changes: 30 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,33 @@
// Required due to: https://github.com/rust-lang/rust/issues/95513
#![allow(unused_crate_dependencies)]

use libcnb_test::{assert_contains, BuildConfig, PackResult, TestRunner};

#[test]
#[ignore = "integration test"]
fn test_successful_detection() {
TestRunner::default().build(
BuildConfig::new(get_integration_test_builder(), "tests/fixtures/basic")
.expected_pack_result(PackResult::Success),
|_| {},
);
}

#[test]
#[ignore = "integration test"]
fn test_failed_detection() {
TestRunner::default().build(
BuildConfig::new(get_integration_test_builder(), "tests/fixtures/no_aptfile")
.expected_pack_result(PackResult::Failure),
|ctx| {
assert_contains!(ctx.pack_stdout, "No Aptfile found.");
},
);
}

const DEFAULT_BUILDER: &str = "heroku/builder:22";

fn get_integration_test_builder() -> String {
std::env::var("INTEGRATION_TEST_CNB_BUILDER").unwrap_or(DEFAULT_BUILDER.to_string())
}

0 comments on commit 6c91816

Please sign in to comment.