Skip to content
Closed
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
27 changes: 27 additions & 0 deletions crates/leo/src/cli/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,33 @@ fn handle_build(command: &LeoBuild, context: Context) -> Result<<LeoBuild as Com
// `leo deploy --rename`: recompile the primary program under a different on-chain name.
let rename_target = apply_rename(command, &mut package, primary_name)?;

let mut pending = vec![build_directory.clone()];
while let Some(path) = pending.pop() {
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound && path == build_directory => continue,
Err(error) => {
return Err(crate::errors::util_file_io_error(
format_args!("Couldn't inspect build output {}", path.display()),
error,
)
.into());
}
};
if metadata.file_type().is_symlink() {
return Err(
crate::errors::custom(format!("Build output must not contain symlinks: {}", path.display())).into()
);
}
if metadata.is_dir() {
for entry in std::fs::read_dir(&path).map_err(crate::errors::failed_to_load_instructions)? {
pending.push(entry.map_err(crate::errors::failed_to_load_instructions)?.path());
}
} else if !metadata.is_file() {
return Err(crate::errors::custom(format!("Expected a regular build output: {}", path.display())).into());
}
}

std::fs::create_dir_all(&build_directory).map_err(|err| {
crate::errors::util_file_io_error(format_args!("Couldn't create directory {}", build_directory.display()), err)
})?;
Expand Down
5 changes: 5 additions & 0 deletions crates/package/src/compilation_unit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,11 @@ fn parse_dependencies_from_aleo(
// Parse the bytecode into an SVM program.
let svm_program: SvmProgram<TestnetV0> =
bytecode.parse().map_err(|_| crate::errors::snarkvm_parsing_error(name))?;
if svm_program.id().to_string() != crate::canonicalize_program_name(&name.to_string()) {
return Err(crate::errors::snarkvm_parsing_error(name)
.with_help(format!("Expected program `{name}`, but the bytecode declares `{}`.", svm_program.id()))
.into());
}
let dependencies = svm_program
.imports()
.keys()
Expand Down
5 changes: 0 additions & 5 deletions crates/package/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,6 @@ pub(crate) fn conflicting_manifest(expected_name: impl Display, manifest_name: i
))
}

pub(crate) fn invalid_network_name(name: impl Display) -> Backtraced {
Backtraced::error(CODE_PREFIX, CODE_MASK + 56, format!("invalid network name `{name}` in `program.json`"))
.with_help("Valid network names are `testnet`, `mainnet`, and `canary`.")
}

pub(crate) fn invalid_manifest_dependency(dep_name: impl Display, reason: impl Display) -> Backtraced {
Backtraced::error(
CODE_PREFIX,
Expand Down
7 changes: 4 additions & 3 deletions crates/package/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ use leo_ast::NetworkName;
use leo_errors::{Backtraced, Result};
use leo_span::Symbol;

use snarkvm::prelude::{ProgramID, TestnetV0};
use std::path::Path;

mod dependency;
Expand Down Expand Up @@ -153,12 +154,12 @@ pub fn canonicalize_program_name(name: &str) -> String {

/// Converts a valid program or library name into a `Symbol`.
///
/// Names must either end with `.aleo` or contain no periods; otherwise an error is returned.
/// Names must be valid Aleo program identifiers or Leo library names.
fn symbol(name: &str) -> Result<Symbol> {
if name.ends_with(".aleo") || !name.contains('.') {
if canonicalize_program_name(name).parse::<ProgramID<TestnetV0>>().is_ok() || is_valid_library_name(name) {
Ok(Symbol::intern(name))
} else {
Err(crate::errors::invalid_network_name(name).into())
Err(crate::errors::cli_invalid_package_name("program or library", name).into())
}
}

Expand Down
57 changes: 57 additions & 0 deletions crates/package/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::*;
use leo_errors::Backtraced;

use serde::{Deserialize, Serialize};
use snarkvm::prelude::{ProgramID, TestnetV0};
use std::path::Path;

pub const MANIFEST_FILENAME: &str = "program.json";
Expand Down Expand Up @@ -60,6 +61,11 @@ impl Manifest {
// Deserialize the manifest.
let manifest: Self = serde_json::from_str(&contents)
.map_err(|err| crate::errors::failed_to_deserialize_manifest_file(path.as_ref().display(), err))?;
if crate::canonicalize_program_name(&manifest.program).parse::<ProgramID<TestnetV0>>().is_err()
&& !crate::is_valid_library_name(&manifest.program)
{
return Err(crate::errors::cli_invalid_package_name("program or library", &manifest.program));
}
manifest.validate_dependencies()?;
manifest.validate_reserved_names()?;
Ok(manifest)
Expand Down Expand Up @@ -119,6 +125,12 @@ impl Dependency {
return invalid(format!("`{location}` dependencies cannot specify `git`"));
}

if crate::canonicalize_program_name(&self.name).parse::<ProgramID<TestnetV0>>().is_err()
&& (self.location == Location::Network || !crate::is_valid_library_name(&self.name))
{
return invalid("a dependency name must be a valid program or library name".to_string());
}

if self.location == Location::Git {
let Some(git) = &self.git else {
return invalid("`git` dependencies must specify `git`".to_string());
Expand Down Expand Up @@ -182,4 +194,49 @@ mod tests {

assert_eq!(manifest.dependencies.unwrap().len(), 3);
}

#[test]
fn manifest_rejects_unsafe_local_names() {
for name in ["../escape.aleo", "/tmp/escape.aleo", r"..\escape.aleo", "foo/bar", "", ".aleo"] {
for location in ["local", "workspace", "test"] {
let mut dependency = serde_json::json!({ "name": name, "location": location });
if location != "workspace" {
dependency["path"] = "../dependency".into();
}
let dependencies = serde_json::json!([dependency]).to_string();
for (normal, dev) in [(dependencies.as_str(), "null"), ("null", dependencies.as_str())] {
read_manifest(&manifest_json(normal, dev)).expect_err("Unsafe dependency names must be rejected.");
}
}
}
}

#[test]
fn manifest_accepts_uppercase_library_names() {
let mut manifest: serde_json::Value =
serde_json::from_str(&manifest_json("null", "null")).expect("The fixture is valid JSON.");
manifest["program"] = "MathLib".into();
read_manifest(&manifest.to_string()).expect("Existing library names must remain valid.");
for location in ["local", "workspace", "test", "git"] {
let mut dependency = serde_json::json!({ "name": "MathLib", "location": location });
match location {
"local" | "test" => dependency["path"] = "../MathLib".into(),
"git" => dependency["git"] = serde_json::json!({ "url": "https://example.com/library.git" }),
_ => (),
}
let dependencies = serde_json::json!([dependency]).to_string();
read_manifest(&manifest_json(&dependencies, "null"))
.expect("Library dependencies must accept existing names.");
}
}

#[test]
fn manifest_rejects_unsafe_primary_names() {
for name in ["../escape.aleo", "/tmp/escape.aleo", r"..\escape.aleo", "foo/bar", "", ".aleo"] {
let mut manifest: serde_json::Value =
serde_json::from_str(&manifest_json("null", "null")).expect("The fixture is valid JSON.");
manifest["program"] = name.into();
read_manifest(&manifest.to_string()).expect_err("Unsafe primary names must be rejected.");
}
}
}
37 changes: 37 additions & 0 deletions crates/package/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,43 @@ function main:
assert_eq!(pkg.unit_build_directory("demo"), PathBuf::from("/tmp/standalone/build/demo"));
}

#[test]
fn unit_symbols_reject_paths() {
create_session_if_not_set_then(|_| {
for name in ["../escape.aleo", "/tmp/escape.aleo", r"..\escape.aleo", "foo/bar", "", ".aleo"] {
crate::symbol(name).expect_err("Unit names must not contain paths.");
}
for name in ["token", "token.aleo", "my_library", "MathLib", "final.aleo", "interface.aleo"] {
assert_eq!(crate::symbol(name).expect("Valid names must be accepted.").to_string(), name);
}
});
}

#[test]
fn local_bytecode_must_match_dependency_identity() {
create_session_if_not_set_then(|_| {
let root = crate::test_util::unique_dir("local-bytecode-identity");
let path = root.join("dependency.aleo");
crate::test_util::write_file(&path, LEAF_PROGRAM);
for name in ["dependency", "dependency.aleo", "../leaf.aleo", "/tmp/leaf.aleo"] {
CompilationUnit::from_aleo_path(Symbol::intern(name), &path, &IndexMap::new())
.expect_err("A local bytecode dependency must have the requested identity.");
}
for name in ["leaf", "leaf.aleo"] {
let unit = CompilationUnit::from_aleo_path(Symbol::intern(name), &path, &IndexMap::new())
.expect("The filename does not need to match the declared program ID.");
assert_eq!(unit.name.to_string(), name);
}
for name in ["final", "interface"] {
let bytecode = LEAF_PROGRAM.replace("leaf.aleo", &format!("{name}.aleo"));
crate::test_util::write_file(&path, &bytecode);
CompilationUnit::from_aleo_path(Symbol::intern(name), &path, &IndexMap::new())
.expect("Valid Aleo names remain valid even when Leo reserves the name.");
}
std::fs::remove_dir_all(root).expect("The fixture should be removed.");
});
}

#[test]
fn aleo_file_uses_sibling_imports_directory() {
create_session_if_not_set_then(|_| {
Expand Down
53 changes: 53 additions & 0 deletions tests/expectations/cli/local_aleo_dependency/COMMANDS
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,56 @@
LEO_BIN=${1}

${LEO_BIN} --disable-update-check build

probe=$(mktemp -d)
cp program.json "$probe/program.json"
cp -R build "$probe/build"
trap 'cp "$probe/program.json" program.json; rm -f escaped.aleo; rm -rf escaped "$probe"' EXIT
printf 'outside sentinel\n' > "$probe/escaped.aleo"
printf 'outside sentinel\n' > escaped.aleo
for name in "$probe/escaped.aleo" ../escaped.aleo; do
printf '{"program":"complex.aleo","version":"0.1.0","description":"","license":"MIT","dependencies":[{"name":"%s","location":"local","path":"simple.aleo"}]}\n' "$name" > program.json
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted a dependency path as its name"
exit 1
fi
[[ "$output" == *"invalid dependency"* ]] || { echo "$output"; exit 1; }
[[ "$(cat "$probe/escaped.aleo")" == "outside sentinel" && "$(cat escaped.aleo)" == "outside sentinel" ]] || exit 1
diff -r "$probe/build" build || exit 1
echo "Rejected dependency path; outside files and build artifacts are unchanged."
done
printf '{"program":"complex.aleo","version":"0.1.0","description":"","license":"MIT","dependencies":[{"name":"different.aleo","location":"local","path":"simple.aleo"}]}\n' > program.json
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted bytecode with a different program ID"
exit 1
fi
[[ "$output" == *'Expected program `different.aleo`, but the bytecode declares `simple.aleo`.'* ]] || { echo "$output"; exit 1; }
diff -r "$probe/build" build || exit 1
echo "Rejected mismatched bytecode; build artifacts are unchanged."
cp "$probe/program.json" program.json
rm -f escaped.aleo
rm -rf "$probe"
trap - EXIT

outside=$(mktemp -d)
mv build build.saved
trap 'rm -rf build "$outside"; mv build.saved build' EXIT

for target in root directory file dangling; do
mkdir -p "$outside/simple"
printf 'sentinel\n' > "$outside/simple/simple.aleo"
case "$target" in
root) ln -s "$outside" build ;;
directory) mkdir build; ln -s "$outside/simple" build/simple ;;
file) mkdir -p build/simple; ln -s "$outside/simple/simple.aleo" build/simple/simple.aleo ;;
dangling) mkdir -p build/simple; ln -s "$outside/missing.aleo" build/simple/simple.aleo ;;
esac
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted a $target symlink"
exit 1
fi
[[ "$output" == *"Build output must not contain symlinks"* ]] || { echo "$output"; exit 1; }
[[ "$(cat "$outside/simple/simple.aleo")" == "sentinel" && ! -e "$outside/missing.aleo" ]] || exit 1
echo "Rejected $target symlink; outside files are unchanged."
rm -rf build
done
7 changes: 7 additions & 0 deletions tests/expectations/cli/local_aleo_dependency/STDOUT
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@
Leo Program size: 0.22 KB / 2000.00 KB
Leo ✅ Compiled 'complex.aleo' into Aleo instructions.
Leo ✅ Generated ABI for program 'complex.aleo'.
Rejected dependency path; outside files and build artifacts are unchanged.
Rejected dependency path; outside files and build artifacts are unchanged.
Rejected mismatched bytecode; build artifacts are unchanged.
Rejected root symlink; outside files are unchanged.
Rejected directory symlink; outside files are unchanged.
Rejected file symlink; outside files are unchanged.
Rejected dangling symlink; outside files are unchanged.
53 changes: 53 additions & 0 deletions tests/tests/cli/local_aleo_dependency/COMMANDS
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,56 @@
LEO_BIN=${1}

${LEO_BIN} --disable-update-check build

probe=$(mktemp -d)
cp program.json "$probe/program.json"
cp -R build "$probe/build"
trap 'cp "$probe/program.json" program.json; rm -f escaped.aleo; rm -rf escaped "$probe"' EXIT
printf 'outside sentinel\n' > "$probe/escaped.aleo"
printf 'outside sentinel\n' > escaped.aleo
for name in "$probe/escaped.aleo" ../escaped.aleo; do
printf '{"program":"complex.aleo","version":"0.1.0","description":"","license":"MIT","dependencies":[{"name":"%s","location":"local","path":"simple.aleo"}]}\n' "$name" > program.json
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted a dependency path as its name"
exit 1
fi
[[ "$output" == *"invalid dependency"* ]] || { echo "$output"; exit 1; }
[[ "$(cat "$probe/escaped.aleo")" == "outside sentinel" && "$(cat escaped.aleo)" == "outside sentinel" ]] || exit 1
diff -r "$probe/build" build || exit 1
echo "Rejected dependency path; outside files and build artifacts are unchanged."
done
printf '{"program":"complex.aleo","version":"0.1.0","description":"","license":"MIT","dependencies":[{"name":"different.aleo","location":"local","path":"simple.aleo"}]}\n' > program.json
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted bytecode with a different program ID"
exit 1
fi
[[ "$output" == *'Expected program `different.aleo`, but the bytecode declares `simple.aleo`.'* ]] || { echo "$output"; exit 1; }
diff -r "$probe/build" build || exit 1
echo "Rejected mismatched bytecode; build artifacts are unchanged."
cp "$probe/program.json" program.json
rm -f escaped.aleo
rm -rf "$probe"
trap - EXIT

outside=$(mktemp -d)
mv build build.saved
trap 'rm -rf build "$outside"; mv build.saved build' EXIT

for target in root directory file dangling; do
mkdir -p "$outside/simple"
printf 'sentinel\n' > "$outside/simple/simple.aleo"
case "$target" in
root) ln -s "$outside" build ;;
directory) mkdir build; ln -s "$outside/simple" build/simple ;;
file) mkdir -p build/simple; ln -s "$outside/simple/simple.aleo" build/simple/simple.aleo ;;
dangling) mkdir -p build/simple; ln -s "$outside/missing.aleo" build/simple/simple.aleo ;;
esac
if output=$(${LEO_BIN} --disable-update-check build 2>&1); then
echo "Build accepted a $target symlink"
exit 1
fi
[[ "$output" == *"Build output must not contain symlinks"* ]] || { echo "$output"; exit 1; }
[[ "$(cat "$outside/simple/simple.aleo")" == "sentinel" && ! -e "$outside/missing.aleo" ]] || exit 1
echo "Rejected $target symlink; outside files are unchanged."
rm -rf build
done