From aff9b9b0b3284b6970c5438a273a0bf6eba7922c Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Wed, 17 Jun 2026 15:45:54 -0700 Subject: [PATCH 1/3] cli: Implement nono sideload When developing a registry pack, it's not easy to test the behavior of the pack, because nono requires the pack to have proper lockfiles and attestation. To solve for this developer case, without harming the security of production nono users, this commit introduces a new compile-time build flag called sideload, which is disabled for production builds. When nono is compiled with sideload, it introduces a new CLI command, nono sideload. This command copies the path into the correct registry location and updates the lockfile like normal. Additionally, other commands like nono run disable attestation Signed-off-by: Anil Kulkarni --- Makefile | 19 +- crates/nono-cli/Cargo.toml | 5 + crates/nono-cli/src/app_runtime.rs | 4 + crates/nono-cli/src/cli.rs | 37 ++ crates/nono-cli/src/cli_bootstrap.rs | 2 + crates/nono-cli/src/main.rs | 25 ++ crates/nono-cli/src/package.rs | 40 +- crates/nono-cli/src/package_cmd.rs | 344 ++++++++++++++++- crates/nono-cli/src/profile_runtime.rs | 9 + crates/nono-cli/tests/sideload.rs | 493 +++++++++++++++++++++++++ 10 files changed, 961 insertions(+), 17 deletions(-) create mode 100644 crates/nono-cli/tests/sideload.rs diff --git a/Makefile b/Makefile index e843b49b1..4b89a9d1b 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # make check Run clippy and format check # make release Build release binaries -.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-lib test-cli test-ffi check clippy fmt clean install audit help +.PHONY: all build build-lib build-cli build-ffi build-arm64 build-sideload test test-lib test-cli test-ffi test-sideload check clippy fmt clean install audit help # Default target all: build @@ -23,6 +23,12 @@ build-cli: build-ffi: cargo build -p nono-ffi +# Build nono-cli with the sideload feature for development/testing. +# WARNING: the resulting binary has integrity protections disabled. +# Do NOT ship this binary. +build-sideload: + cargo build -p nono-cli --features sideload + build-release: cargo build --release @@ -48,6 +54,11 @@ test-lib: test-cli: cargo test -p nono-cli +# Run the sideload-gated integration tests. Requires --features sideload so +# both the test binary and the nono binary under test have the feature active. +test-sideload: + cargo test -p nono-cli --features sideload + test-ffi: cargo test -p nono-ffi @@ -118,7 +129,7 @@ lint-docs: bash scripts/lint-docs.sh # CI simulation (what CI would run) -ci: check test audit lint-aliases lint-docs +ci: check test test-sideload audit lint-aliases lint-docs @echo "CI checks passed" # Help @@ -130,13 +141,15 @@ help: @echo " make build-lib Build library only" @echo " make build-cli Build CLI only" @echo " make build-ffi Build C FFI bindings" + @echo " make build-sideload Build CLI with --features sideload (dev/test only)" @echo " make build-release Build release binaries" @echo " make build-arm64 Build CLI for Linux ARM64 (cargo on Linux ARM64; cross elsewhere)" @echo "" @echo "Test:" @echo " make test Run all tests" @echo " make test-lib Run library tests only" - @echo " make test-cli Run CLI tests only" + @echo " make test-cli Run CLI tests only (production build)" + @echo " make test-sideload Run CLI tests with --features sideload" @echo " make test-ffi Run C FFI tests only" @echo " make test-doc Run doc tests only" @echo "" diff --git a/crates/nono-cli/Cargo.toml b/crates/nono-cli/Cargo.toml index a89fdc5de..e97c10b2c 100644 --- a/crates/nono-cli/Cargo.toml +++ b/crates/nono-cli/Cargo.toml @@ -39,6 +39,11 @@ default = ["system-keyring"] system-keyring = ["dep:keyring", "nono/system-keyring", "nono-proxy/system-keyring"] # Enables test-only trust root overrides used by the integration harness. test-trust-overrides = [] +# Enables the `nono sideload` subcommand which installs packs from a local +# directory without registry attestation or cryptographic verification. +# WARNING: integrity protections are DISABLED when this feature is active. +# Do NOT ship production binaries built with this flag. +sideload = [] [dependencies] nono = { version = "0.68.0", path = "../nono", default-features = false } diff --git a/crates/nono-cli/src/app_runtime.rs b/crates/nono-cli/src/app_runtime.rs index 80af74870..58f2d7f53 100644 --- a/crates/nono-cli/src/app_runtime.rs +++ b/crates/nono-cli/src/app_runtime.rs @@ -122,6 +122,10 @@ fn dispatch_command( Commands::Outdated(args) => { run_command_with_update(update_handle, silent, || package_cmd::run_outdated(args)) } + #[cfg(feature = "sideload")] + Commands::Sideload(args) => { + run_command_with_update(update_handle, silent, || package_cmd::run_sideload(args)) + } Commands::OpenUrlHelper(args) => run_open_url_helper(args), Commands::PackUpdateHintHelper(args) => crate::pack_update_hint::run_refresh_helper(args), Commands::Completions(args) => run_completions(args), diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index 4896deee1..f0b9ab9e2 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -57,6 +57,7 @@ const STYLES: Styles = Styles::plain().header(Style::new().bold()); unpin Unpin a pack to re-include it in updates search Search the registry for nono packs list List installed nono packs + sideload Install a pack from a local directory (--features sideload builds only) \x1b[1mPOLICY & PROFILES\x1b[0m policy [deprecated] Use 'nono profile' instead @@ -686,6 +687,25 @@ IN-BAND DETACH: ")] Completions(CompletionsArgs), + /// Install a nono pack from a local directory, bypassing registry attestation. + /// + /// WARNING: integrity protections are DISABLED for sideloaded packs. + /// Use `nono remove` to uninstall. Do NOT use on production systems. + #[cfg(feature = "sideload")] + #[command(help_template = "\ +{about} + +\x1b[1mUSAGE\x1b[0m + nono sideload + +{all-args} +{after-help}")] + #[command(after_help = "\x1b[1mEXAMPLES\x1b[0m + nono sideload ./my-pack # Install from local directory + nono sideload /abs/path/to/pack # Install from absolute path +")] + Sideload(SideloadArgs), + /// Internal: open a URL via supervisor IPC #[command(hide = true)] OpenUrlHelper(OpenUrlHelperArgs), @@ -866,6 +886,20 @@ pub struct OpenUrlHelperArgs { pub url: String, } +/// Arguments for `nono sideload`. Only present when the binary is compiled +/// with `--features sideload`. Do NOT use on production systems. +#[cfg(feature = "sideload")] +#[derive(Parser, Debug)] +#[command(disable_help_flag = true)] +pub struct SideloadArgs { + /// Path to the local pack directory (must contain package.json) + pub path: PathBuf, + + /// Print help + #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] + pub help: Option, +} + /// Arguments for the hidden pack-update-hint-helper subcommand. /// /// Invoked by `nono run` to refresh stale pack update hint cache entries in a @@ -4408,6 +4442,9 @@ mod tests { "search", "list", "completion", + // Only present when compiled with --features sideload. + #[cfg(feature = "sideload")] + "sideload", ]; #[test] diff --git a/crates/nono-cli/src/cli_bootstrap.rs b/crates/nono-cli/src/cli_bootstrap.rs index d7f83a29b..3f46f62d2 100644 --- a/crates/nono-cli/src/cli_bootstrap.rs +++ b/crates/nono-cli/src/cli_bootstrap.rs @@ -240,6 +240,8 @@ fn cli_verbosity(cli: &Cli) -> u8 { | Commands::OpenUrlHelper(_) | Commands::PackUpdateHintHelper(_) | Commands::Completions(_) => 0, + #[cfg(feature = "sideload")] + Commands::Sideload(_) => 0, } } diff --git a/crates/nono-cli/src/main.rs b/crates/nono-cli/src/main.rs index 3d0cde2ab..c6c4f5bd9 100644 --- a/crates/nono-cli/src/main.rs +++ b/crates/nono-cli/src/main.rs @@ -2,6 +2,16 @@ //! //! This is the CLI binary that uses the nono library for OS-level sandboxing. +// The sideload feature disables integrity protections and must never appear in +// a release binary. `debug_assertions` is false in `--release` builds, so this +// fires exactly when someone runs `cargo build --release --features sideload`. +#[cfg(all(feature = "sideload", not(debug_assertions)))] +compile_error!( + "The `sideload` feature must not be enabled in release builds. \ + Remove `--features sideload` from the release build command. \ + Sideload binaries are for development and testing only." +); + mod app_runtime; mod approval_runtime; mod audit_attestation; @@ -129,6 +139,21 @@ fn main() { let command_blocking_warnings = collect_cli_warnings(&cli); print_deprecation_warnings(&command_blocking_warnings, cli.silent); + // Global sideload warning: printed before every command so there is no + // ambiguity about which binary is running. One banner per invocation, + // always on stderr, never suppressed by --silent (the risk is the reason + // for the warning). + #[cfg(feature = "sideload")] + { + use colored::Colorize; + eprintln!( + "{}", + "warning: nono was compiled with --features sideload, which means integrity \ + protections are DISABLED. Do NOT use this binary on production systems." + .red() + ); + } + let cli_result = run_cli(cli); tool_sandbox::log_main_total(); if let Err(e) = cli_result { diff --git a/crates/nono-cli/src/package.rs b/crates/nono-cli/src/package.rs index bcd1c502d..1c237551d 100644 --- a/crates/nono-cli/src/package.rs +++ b/crates/nono-cli/src/package.rs @@ -131,6 +131,20 @@ pub struct LockedPackage { /// removed from the registry between install and uninstall. #[serde(default)] pub wiring_record: Vec, + /// True when the pack was installed via `nono sideload` rather than pulled + /// from the registry. Sideloaded packs have no attestation, no bundle, and + /// no registry provenance. They are skipped by `nono update` / `nono + /// outdated` and their files are used as-is at runtime without signature + /// verification. + /// + /// Defense-in-depth: `read_lockfile` hard-errors on any entry with + /// `sideload: true` when the binary was compiled without the `sideload` + /// feature, so production binaries never silently accept unverified packs. + /// + /// Omitted from JSON output when `false` to avoid bloating the lockfile for + /// the common (registry-installed) case. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sideload: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -160,6 +174,7 @@ impl Default for LockedPackage { provenance: None, artifacts: BTreeMap::new(), wiring_record: Vec::new(), + sideload: false, } } } @@ -347,8 +362,29 @@ pub fn read_lockfile() -> Result { source: e, })?; - serde_json::from_str(&content) - .map_err(|e| NonoError::ConfigParse(format!("failed to parse {}: {e}", path.display()))) + let lockfile: Lockfile = serde_json::from_str(&content) + .map_err(|e| NonoError::ConfigParse(format!("failed to parse {}: {e}", path.display())))?; + + // Defense-in-depth: reject sideloaded packs on production binaries. + // A production binary compiled without `--features sideload` must never + // silently accept packs that bypassed attestation and signature checks. + #[cfg(not(feature = "sideload"))] + { + let sideloaded: Vec<&str> = lockfile + .packages + .iter() + .filter(|(_, pkg)| pkg.sideload) + .map(|(key, _)| key.as_str()) + .collect(); + if !sideloaded.is_empty() { + let names = sideloaded.join(", "); + return Err(NonoError::PackageInstall(format!( + "lockfile contains sideloaded pack(s) [{names}]; remove with `nono remove `." + ))); + } + } + + Ok(lockfile) } pub fn write_lockfile(lockfile: &Lockfile) -> Result<()> { diff --git a/crates/nono-cli/src/package_cmd.rs b/crates/nono-cli/src/package_cmd.rs index dfeed1364..f878ce7f6 100644 --- a/crates/nono-cli/src/package_cmd.rs +++ b/crates/nono-cli/src/package_cmd.rs @@ -27,9 +27,18 @@ pub fn run_pull(args: PullArgs, reason: PullReason) -> Result<()> { validate_pull_response(&package_ref, &pull)?; let lockfile = package::read_lockfile()?; - if let Some(existing) = lockfile.packages.get(&package_ref.key()) + + let existing = lockfile.packages.get(&package_ref.key()); + + #[cfg(feature = "sideload")] + let is_sideloaded = existing.is_some_and(|p| p.sideload); + #[cfg(not(feature = "sideload"))] + let is_sideloaded = false; + + if let Some(existing) = existing && existing.version == pull.version && !args.force + && !is_sideloaded { eprintln!( " {} is already at {} (use --force to reinstall)", @@ -203,6 +212,244 @@ pub fn run_remove(args: RemoveArgs) -> Result<()> { Ok(()) } +/// Install a nono pack from a local directory, bypassing registry attestation. +/// +/// This command is only available when the binary is compiled with +/// `--features sideload`. It is intended for development and testing workflows +/// where the pack has not yet been published to the registry. All attestation, +/// signature, and digest verification is skipped — the on-disk files are used +/// as-is. +/// +/// Security: the resulting lockfile entry has `sideload: true` and `pinned: +/// true`. A production binary (compiled without `sideload`) will hard-error if +/// it encounters such an entry in the lockfile. +#[cfg(feature = "sideload")] +pub fn run_sideload(args: crate::cli::SideloadArgs) -> Result<()> { + let src_path = args + .path + .canonicalize() + .map_err(|e| NonoError::PackageInstall(format!("cannot resolve path: {e}")))?; + + // Read package.json from the source directory to derive namespace/name/version. + let manifest_path = src_path.join("package.json"); + if !manifest_path.exists() { + return Err(NonoError::PackageInstall(format!( + "no package.json found in {}", + src_path.display() + ))); + } + let manifest_bytes = fs::read(&manifest_path).map_err(NonoError::Io)?; + let manifest: PackageManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|e| NonoError::PackageInstall(format!("failed to parse package.json: {e}")))?; + + // Derive namespace/name from manifest name field (expected "namespace/name"). + let package_ref = derive_package_ref_from_manifest(&manifest)?; + let version = manifest + .version + .clone() + .unwrap_or_else(|| "0.0.0".to_string()); + + eprintln!( + "sideload: installing {}@{} from {}", + package_ref.key(), + version, + src_path.display() + ); + + // If the pack is already installed (registry or prior sideload), remove it + // first so wiring is cleanly reversed. + let lockfile = package::read_lockfile()?; + let install_dir = package::package_install_dir(&package_ref.namespace, &package_ref.name)?; + if lockfile.packages.contains_key(&package_ref.key()) || install_dir.exists() { + eprintln!( + " sideload: {} already installed — removing existing install first", + package_ref.key() + ); + run_remove(crate::cli::RemoveArgs { + package_ref: package_ref.key(), + force: false, + help: None, + })?; + } + + // Re-read lockfile after remove. + let lockfile = package::read_lockfile()?; + + validate_manifest(&manifest)?; + + // Build a downloads-like structure from the local source directory so we + // can reuse `install_package`. We treat each artifact file as already + // "downloaded" (path is the source file, digest is computed locally). + let local_downloads = build_local_downloads(&src_path, &manifest)?; + + let pack_owned_files = pack_owned_write_file_paths(&lockfile, &package_ref); + let install = + install_package_sideload(&package_ref, &manifest, &local_downloads, &pack_owned_files)?; + + // Write the lockfile entry with sideload: true, pinned: true, no provenance. + write_sideload_lockfile_entry( + &package_ref, + &version, + &manifest, + &local_downloads, + &install.wiring_record, + )?; + + let install_dir = package::package_install_dir(&package_ref.namespace, &package_ref.name)?; + eprintln!( + " sideloaded {}@{} → {} ({} artifact(s))", + package_ref.key(), + version, + install_dir.display(), + install.installed_artifacts + ); + eprintln!(" WARNING: this pack has no attestation — do not use on production systems"); + Ok(()) +} + +/// Derive a `PackageRef` from a `PackageManifest`. The manifest's `name` field +/// must be in `namespace/name` form (e.g. `"acme/my-pack"`). +#[cfg(feature = "sideload")] +fn derive_package_ref_from_manifest(manifest: &PackageManifest) -> Result { + package::parse_package_ref(&manifest.name) +} + +/// Represents a locally-sourced artifact (no download URL, digest computed +/// from disk). +#[cfg(feature = "sideload")] +struct LocalArtifact { + filename: String, + path: PathBuf, + sha256_digest: String, +} + +#[cfg(feature = "sideload")] +struct LocalDownloads { + artifacts: Vec, +} + +/// Build local-artifact metadata from the source directory for a sideload. +#[cfg(feature = "sideload")] +fn build_local_downloads(src_path: &Path, manifest: &PackageManifest) -> Result { + let mut artifacts = Vec::new(); + + // Include package.json itself. + let manifest_path = src_path.join("package.json"); + let digest = local_file_digest(&manifest_path)?; + artifacts.push(LocalArtifact { + filename: "package.json".to_string(), + path: manifest_path, + sha256_digest: digest, + }); + + // Include every artifact declared in the manifest. + for artifact in &manifest.artifacts { + // Reject absolute paths and any '..' components before joining with + // the source directory. Without this check, an attacker-controlled + // artifact.path such as "/etc/passwd" or "../../sensitive" would + // escape src_path and allow arbitrary file reads. + validate_relative_path(&artifact.path)?; + let src_file = src_path.join(&artifact.path); + if !src_file.exists() { + return Err(NonoError::PackageInstall(format!( + "manifest references missing artifact '{}' (expected at {})", + artifact.path, + src_file.display() + ))); + } + let digest = local_file_digest(&src_file)?; + artifacts.push(LocalArtifact { + filename: artifact.path.clone(), + path: src_file, + sha256_digest: digest, + }); + } + + Ok(LocalDownloads { artifacts }) +} + +/// Compute SHA-256 hex digest of a local file. +#[cfg(feature = "sideload")] +fn local_file_digest(path: &Path) -> Result { + nono::trust::file_digest(path) +} + +/// Install a sideloaded pack into the pack store. +#[cfg(feature = "sideload")] +fn install_package_sideload( + package_ref: &PackageRef, + manifest: &PackageManifest, + downloads: &LocalDownloads, + pack_owned_files: &HashMap, +) -> Result { + install_package_inner(package_ref, manifest, downloads, false, pack_owned_files) +} + +/// Write a lockfile entry for a sideloaded pack. +#[cfg(feature = "sideload")] +fn write_sideload_lockfile_entry( + package_ref: &PackageRef, + version: &str, + manifest: &PackageManifest, + downloads: &LocalDownloads, + wiring_record: &[crate::wiring::WiringRecord], +) -> Result<()> { + let mut lockfile = package::read_lockfile()?; + lockfile.lockfile_version = package::LOCKFILE_VERSION; + // Sideload entries do not set a registry URL (no registry involved). + + let downloaded_by_name = downloads + .artifacts + .iter() + .map(|a| (a.filename.as_str(), a)) + .collect::>(); + + let mut artifacts = BTreeMap::new(); + for artifact in &manifest.artifacts { + let downloaded = downloaded_by_name + .get(artifact.path.as_str()) + .ok_or_else(|| { + NonoError::PackageInstall(format!( + "manifest references missing artifact '{}'", + artifact.path + )) + })?; + let installed_path = installed_artifact_relative_path(artifact)?; + if artifacts + .insert( + installed_path.clone(), + LockedArtifact { + sha256: downloaded.sha256_digest.clone(), + artifact_type: artifact.artifact_type.clone(), + }, + ) + .is_some() + { + return Err(NonoError::PackageInstall(format!( + "multiple artifacts install to the same path '{}' (conflict at '{}')", + installed_path, artifact.path + ))); + } + } + + lockfile.packages.insert( + package_ref.key(), + LockedPackage { + version: version.to_string(), + installed_at: Utc::now().to_rfc3339(), + // Sideloaded packs are pinned by default to prevent accidental + // upgrades. Users must explicitly re-sideload or `nono pull` to upgrade. + pinned: true, + provenance: None, + artifacts, + wiring_record: wiring_record.to_vec(), + sideload: true, + }, + ); + + package::write_lockfile(&lockfile) +} + /// Collect the absolute paths and prior SHA-256 of `WriteFile` /// destinations recorded against this exact pack in the lockfile. /// The wiring interpreter uses both pieces to allow idempotent @@ -421,7 +668,11 @@ pub fn run_list(args: ListArgs) -> Result<()> { for (name, pkg) in lockfile.packages { let installed_at = format_timestamp(&pkg.installed_at); - println!("{name}\t{}\t{installed_at}", pkg.version); + #[cfg(feature = "sideload")] + let annotation = if pkg.sideload { " [sideload]" } else { "" }; + #[cfg(not(feature = "sideload"))] + let annotation = ""; + println!("{name}\t{}{annotation}\t{installed_at}", pkg.version); } return Ok(()); } @@ -521,6 +772,12 @@ pub fn run_outdated(args: OutdatedArgs) -> Result<()> { continue; }; + // Sideloaded packs have no registry record — skip them entirely. + #[cfg(feature = "sideload")] + if pkg.sideload { + continue; + } + let pkg_ref = package::PackageRef { namespace: namespace.to_string(), name: name.to_string(), @@ -605,6 +862,58 @@ struct InstallSummary { wiring_record: Vec, } +/// A borrowed view of a single artifact used by `install_package_inner`. +struct ArtifactRef<'a> { + filename: &'a str, + path: &'a Path, +} + +/// Abstraction over the two artifact sources: verified registry downloads and +/// local sideload files. Implementations provide iteration over artifacts and +/// handle the source-specific staging step (writing `package.json` and, for +/// registry installs, the `.nono-trust.bundle`). +trait ArtifactSource { + fn artifact_refs(&self) -> Vec>; + fn write_supporting(&self, staging_root: &Path, manifest: &PackageManifest) -> Result<()>; +} + +impl ArtifactSource for VerifiedDownloads { + fn artifact_refs(&self) -> Vec> { + self.artifacts + .iter() + .map(|a| ArtifactRef { + filename: &a.filename, + path: &a.path, + }) + .collect() + } + + fn write_supporting(&self, staging_root: &Path, manifest: &PackageManifest) -> Result<()> { + write_supporting_artifacts(staging_root, manifest, self) + } +} + +#[cfg(feature = "sideload")] +impl ArtifactSource for LocalDownloads { + fn artifact_refs(&self) -> Vec> { + self.artifacts + .iter() + .map(|a| ArtifactRef { + filename: &a.filename, + path: &a.path, + }) + .collect() + } + + fn write_supporting(&self, staging_root: &Path, _manifest: &PackageManifest) -> Result<()> { + // Sideloaded packs: copy package.json only. No .nono-trust.bundle. + if let Some(pkg_json) = self.artifacts.iter().find(|a| a.filename == "package.json") { + copy_path(&pkg_json.path, &staging_root.join("package.json"))?; + } + Ok(()) + } +} + fn validate_pull_response(package_ref: &PackageRef, pull: &PullResponse) -> Result<()> { if pull.namespace != package_ref.namespace || pull.name != package_ref.name { return Err(NonoError::PackageVerification { @@ -767,10 +1076,12 @@ fn validate_manifest(manifest: &PackageManifest) -> Result<()> { Ok(()) } -fn install_package( +/// Shared staging, artifact installation, and wiring logic used by both +/// `install_package` (registry) and `install_package_sideload` (local). +fn install_package_inner( package_ref: &PackageRef, manifest: &PackageManifest, - downloads: &VerifiedDownloads, + source: &S, init: bool, pack_owned_files: &HashMap, ) -> Result { @@ -782,14 +1093,12 @@ fn install_package( let staging_root = tempdir.path().join(&package_ref.name); fs::create_dir_all(&staging_root).map_err(NonoError::Io)?; - let mut downloaded_by_name: HashMap<&str, &DownloadedArtifact> = - HashMap::with_capacity(downloads.artifacts.len()); - for artifact in &downloads.artifacts { - downloaded_by_name.insert(artifact.filename.as_str(), artifact); - } + let refs = source.artifact_refs(); + let downloaded_by_name: HashMap<&str, &ArtifactRef<'_>> = + refs.iter().map(|a| (a.filename, a)).collect(); validate_manifest_install_paths(manifest)?; - write_supporting_artifacts(&staging_root, manifest, downloads)?; + source.write_supporting(&staging_root, manifest)?; let mut copied_to_project = 0usize; for artifact in &manifest.artifacts { @@ -801,12 +1110,12 @@ fn install_package( artifact.path )) })?; - install_manifest_artifact(&staging_root, artifact, &downloaded.path)?; + install_manifest_artifact(&staging_root, artifact, downloaded.path)?; if init && artifact.artifact_type == ArtifactType::Instruction && artifact.placement.as_deref() == Some("project") { - copy_instruction_to_project(artifact, &downloaded.path)?; + copy_instruction_to_project(artifact, downloaded.path)?; copied_to_project = copied_to_project.saturating_add(1); } } @@ -848,6 +1157,16 @@ fn install_package( }) } +fn install_package( + package_ref: &PackageRef, + manifest: &PackageManifest, + downloads: &VerifiedDownloads, + init: bool, + pack_owned_files: &HashMap, +) -> Result { + install_package_inner(package_ref, manifest, downloads, init, pack_owned_files) +} + fn write_supporting_artifacts( staging_root: &Path, manifest: &PackageManifest, @@ -1058,6 +1377,7 @@ fn update_lockfile( }), artifacts, wiring_record: wiring_record.to_vec(), + sideload: false, }, ); diff --git a/crates/nono-cli/src/profile_runtime.rs b/crates/nono-cli/src/profile_runtime.rs index 706f7482e..7e7ed58e0 100644 --- a/crates/nono-cli/src/profile_runtime.rs +++ b/crates/nono-cli/src/profile_runtime.rs @@ -199,6 +199,15 @@ fn verify_profile_packs(packs: &[String], profile: &profile::Profile) -> crate:: } } + // Sideloaded packs are installed without a Sigstore bundle — skip + // attestation verification when the sideload feature is active and the + // lockfile entry is marked as sideloaded. The SHA-256 digest checks + // above still run, preserving on-disk integrity verification. + #[cfg(feature = "sideload")] + if locked_pkg.sideload { + continue; + } + let bundle_path = install_dir.join(".nono-trust.bundle"); if !bundle_path.exists() { return Err(nono::NonoError::PackageVerification { diff --git a/crates/nono-cli/tests/sideload.rs b/crates/nono-cli/tests/sideload.rs new file mode 100644 index 000000000..995a0d4d5 --- /dev/null +++ b/crates/nono-cli/tests/sideload.rs @@ -0,0 +1,493 @@ +//! Sideload feature tests. +//! +//! Two cfg sections with different run instructions: +//! +//! **Production build** (`#[cfg(not(feature = "sideload"))]`): +//! Verifies the lockfile guard hard-errors on sideloaded entries. +//! Run with: `cargo test -p nono-cli` +//! +//! **Sideload build** (`#[cfg(feature = "sideload")]`): +//! Functional tests for `nono sideload` and related commands. +//! Run with: `cargo test -p nono-cli --features sideload` + +// --------------------------------------------------------------------------- +// Production build: lockfile defense-in-depth +// --------------------------------------------------------------------------- + +/// On a production binary (compiled without --features sideload), a lockfile +/// containing `sideload: true` must cause hard failure with a clear message. +#[cfg(not(feature = "sideload"))] +mod production { + fn nono_bin() -> std::process::Command { + std::process::Command::new(env!("CARGO_BIN_EXE_nono")) + } + + #[test] + fn lockfile_with_sideload_entry_hard_errors() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let packages_dir = cfg.path().join("nono").join("packages"); + std::fs::create_dir_all(&packages_dir).expect("create packages dir"); + + std::fs::write( + packages_dir.join("lockfile.json"), + r#"{ + "lockfile_version": 4, + "registry": "", + "packages": { + "acme/evil-pack": { + "version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "sideload": true + } + } +}"#, + ) + .expect("write lockfile"); + + let output = nono_bin() + .args(["list", "--installed"]) + .env("XDG_CONFIG_HOME", cfg.path()) + .output() + .expect("spawn nono"); + + assert!( + !output.status.success(), + "expected hard error on production binary for sideload entry" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("sideload"), + "error must mention sideload, got:\n{stderr}" + ); + assert!( + stderr.contains("acme/evil-pack"), + "error must name the offending pack, got:\n{stderr}" + ); + } + + #[test] + fn lockfile_without_sideload_entries_loads_cleanly() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let packages_dir = cfg.path().join("nono").join("packages"); + std::fs::create_dir_all(&packages_dir).expect("create packages dir"); + + std::fs::write( + packages_dir.join("lockfile.json"), + r#"{ + "lockfile_version": 4, + "registry": "https://registry.nono.sh", + "packages": {} +}"#, + ) + .expect("write lockfile"); + + let output = nono_bin() + .args(["list", "--installed"]) + .env("XDG_CONFIG_HOME", cfg.path()) + .output() + .expect("spawn nono"); + + assert!( + output.status.success(), + "expected clean load for lockfile without sideload entries, stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn sideload_subcommand_absent_on_production_binary() { + let output = nono_bin() + .args(["sideload", "/tmp"]) + .output() + .expect("spawn nono"); + + assert!( + !output.status.success(), + "expected clap to reject unknown subcommand on production binary" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unrecognized") || stderr.contains("error"), + "expected clap error for unknown subcommand, got:\n{stderr}" + ); + } +} + +// --------------------------------------------------------------------------- +// Sideload build: functional tests +// --------------------------------------------------------------------------- + +#[cfg(feature = "sideload")] +mod sideload_enabled { + use std::path::Path; + + fn nono_bin() -> std::process::Command { + std::process::Command::new(env!("CARGO_BIN_EXE_nono")) + } + + /// Write a minimal valid pack fixture to `dir`. + /// The manifest `name` field must be `namespace/name` for `nono sideload`. + fn write_fixture_pack(dir: &Path, namespace: &str, name: &str, version: &str) { + let manifest = serde_json::json!({ + "schema_version": 1, + "name": format!("{namespace}/{name}"), + "version": version, + "description": "test fixture pack", + "artifacts": [ + { + "type": "profile", + "path": "profile.json", + "install_as": format!("{namespace}-{name}") + } + ], + "wiring": [] + }); + std::fs::write( + dir.join("package.json"), + serde_json::to_string_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write package.json"); + + std::fs::write( + dir.join("profile.json"), + r#"{"meta":{"name":"test","description":"test"}}"#, + ) + .expect("write profile.json"); + } + + /// Run `nono ` with an isolated XDG config root. + fn run_nono(args: &[&str], config_home: &Path) -> (String, String, bool) { + let output = nono_bin() + .args(args) + .env("XDG_CONFIG_HOME", config_home) + .output() + .expect("spawn nono"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.success(), + ) + } + + fn read_lockfile(config_home: &Path) -> serde_json::Value { + let path = config_home + .join("nono") + .join("packages") + .join("lockfile.json"); + serde_json::from_str(&std::fs::read_to_string(path).expect("read lockfile")) + .expect("parse lockfile") + } + + // ── startup banner ─────────────────────────────────────────────────────── + + #[test] + fn startup_banner_warns_about_sideload_feature() { + let output = nono_bin() + .args(["list", "--installed"]) + .output() + .expect("spawn nono"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("integrity") && stderr.contains("DISABLED"), + "expected sideload warning banner, got:\n{stderr}" + ); + } + + // ── install ────────────────────────────────────────────────────────────── + + #[test] + fn sideload_installs_pack_from_local_directory() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "my-pack", "0.1.0"); + + let (_, stderr, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok, "sideload failed:\n{stderr}"); + assert!( + stderr.contains("acme/my-pack"), + "pack name missing:\n{stderr}" + ); + assert!(stderr.contains("0.1.0"), "version missing:\n{stderr}"); + + // Install directory must exist. + let install_dir = cfg + .path() + .join("nono") + .join("packages") + .join("acme") + .join("my-pack"); + assert!(install_dir.exists(), "install dir not created"); + + // Lockfile: sideload: true, pinned: true, no provenance, no bundle. + let lf = read_lockfile(cfg.path()); + let entry = &lf["packages"]["acme/my-pack"]; + assert_eq!(entry["sideload"], true); + assert_eq!(entry["pinned"], true); + assert_eq!(entry["version"], "0.1.0"); + assert!( + entry.get("provenance").is_none() || entry["provenance"].is_null(), + "no provenance expected for sideloaded pack" + ); + assert!( + !install_dir.join(".nono-trust.bundle").exists(), + "sideloaded pack must not have a .nono-trust.bundle" + ); + } + + #[test] + fn sideload_over_existing_install_auto_removes_then_reinstalls() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "replaceable", "0.1.0"); + + let (_, _, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok); + + // Bump version, sideload again. + write_fixture_pack(src.path(), "acme", "replaceable", "0.2.0"); + let (_, stderr, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok, "re-sideload failed:\n{stderr}"); + assert!( + stderr.contains("already installed") || stderr.contains("removing"), + "expected removal notice:\n{stderr}" + ); + + let lf = read_lockfile(cfg.path()); + assert_eq!( + lf["packages"]["acme/replaceable"]["version"], "0.2.0", + "lockfile must reflect updated version" + ); + } + + // ── list ───────────────────────────────────────────────────────────────── + + #[test] + fn nono_list_annotates_sideloaded_entries() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "listed", "1.0.0"); + + let (_, _, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok); + + let (stdout, stderr, ok) = run_nono(&["list", "--installed"], cfg.path()); + assert!(ok, "list failed:\n{stderr}"); + assert!( + stdout.contains("[sideload]"), + "expected [sideload] annotation:\n{stdout}" + ); + assert!( + stdout.contains("acme/listed"), + "pack name missing:\n{stdout}" + ); + } + + // ── update ─────────────────────────────────────────────────────────────── + + #[test] + fn nono_update_silently_skips_sideloaded_entries() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "skipped", "1.0.0"); + + let (_, _, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok); + + // update with only sideloaded (pinned) packs must exit 0 and not hit the registry. + let (_, stderr, ok) = run_nono(&["update"], cfg.path()); + assert!( + ok, + "update must succeed with only sideloaded packs:\n{stderr}" + ); + assert!( + !stderr.contains("registry") && !stderr.contains("http"), + "update must not query registry for sideloaded packs:\n{stderr}" + ); + } + + // ── outdated ───────────────────────────────────────────────────────────── + + #[test] + fn nono_outdated_skips_sideloaded_entries() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "outdated-test", "1.0.0"); + + let (_, _, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok); + + let (stdout, stderr, ok) = run_nono(&["outdated"], cfg.path()); + assert!(ok, "outdated must succeed:\n{stderr}"); + assert!( + !stdout.contains("acme/outdated-test"), + "sideloaded pack must not appear in outdated output:\n{stdout}" + ); + } + + // ── path traversal / arbitrary file read ───────────────────────────────── + + /// A manifest whose artifact.path is an absolute path (e.g. /etc/passwd) + /// must be rejected before any filesystem access. + #[test] + fn sideload_rejects_absolute_artifact_path() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + + // Write a manifest with an absolute path in the artifact list. + let manifest = serde_json::json!({ + "schema_version": 1, + "name": "acme/evil-abs", + "version": "1.0.0", + "description": "adversarial fixture", + "artifacts": [ + { + "type": "trust_policy", + "path": "/etc/passwd" + } + ], + "wiring": [] + }); + std::fs::write( + src.path().join("package.json"), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write package.json"); + + let (_, stderr, ok) = run_nono( + &["sideload", src.path().to_str().expect("path")], + cfg.path(), + ); + assert!(!ok, "sideload must fail for absolute artifact path"); + assert!( + stderr.contains("relative") || stderr.contains("absolute") || stderr.contains("path"), + "error must mention path validation, got:\n{stderr}" + ); + } + + /// A manifest whose artifact.path contains '..' must be rejected before + /// any filesystem access to prevent directory traversal. + #[test] + fn sideload_rejects_dotdot_artifact_path() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + + let manifest = serde_json::json!({ + "schema_version": 1, + "name": "acme/evil-dotdot", + "version": "1.0.0", + "description": "adversarial fixture", + "artifacts": [ + { + "type": "groups", + "path": "../../sensitive-file" + } + ], + "wiring": [] + }); + std::fs::write( + src.path().join("package.json"), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write package.json"); + + let (_, stderr, ok) = run_nono( + &["sideload", src.path().to_str().expect("path")], + cfg.path(), + ); + assert!(!ok, "sideload must fail for '..'-containing artifact path"); + assert!( + stderr.contains("..") || stderr.contains("relative") || stderr.contains("path"), + "error must mention path validation, got:\n{stderr}" + ); + } + + /// A profile artifact whose path contains '..' must also be rejected. + /// This covers a type that previously went through validate_safe_name on + /// install_as but not validate_relative_path on the source path. + #[test] + fn sideload_rejects_dotdot_profile_artifact_path() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + + let manifest = serde_json::json!({ + "schema_version": 1, + "name": "acme/evil-profile", + "version": "1.0.0", + "description": "adversarial fixture", + "artifacts": [ + { + "type": "profile", + "path": "../outside.json", + "install_as": "safe-name" + } + ], + "wiring": [] + }); + std::fs::write( + src.path().join("package.json"), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write package.json"); + + let (_, stderr, ok) = run_nono( + &["sideload", src.path().to_str().expect("path")], + cfg.path(), + ); + assert!(!ok, "sideload must fail for '..'-profile artifact path"); + assert!( + stderr.contains("..") || stderr.contains("relative") || stderr.contains("path"), + "error must mention path validation, got:\n{stderr}" + ); + } + + // ── remove ─────────────────────────────────────────────────────────────── + + #[test] + fn nono_remove_reverses_sideloaded_pack() { + let cfg = tempfile::tempdir().expect("cfg dir"); + let src = tempfile::tempdir().expect("pack src"); + write_fixture_pack(src.path(), "acme", "removable", "1.0.0"); + + let (_, _, ok) = run_nono( + &["sideload", src.path().to_str().expect("path to str")], + cfg.path(), + ); + assert!(ok); + + let install_dir = cfg + .path() + .join("nono") + .join("packages") + .join("acme") + .join("removable"); + assert!(install_dir.exists(), "pack not installed before remove"); + + let (_, stderr, ok) = run_nono(&["remove", "acme/removable"], cfg.path()); + assert!(ok, "remove failed:\n{stderr}"); + assert!(!install_dir.exists(), "install dir must be cleaned up"); + + let lf = read_lockfile(cfg.path()); + assert!( + lf["packages"].get("acme/removable").is_none(), + "lockfile must not contain removed pack" + ); + } +} From 505a3d44a3ea72a703afd317df25845f18822a85 Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Wed, 17 Jun 2026 16:27:50 -0700 Subject: [PATCH 2/3] Update the docs for sideloading Signed-off-by: Anil Kulkarni --- docs/cli/features/package-publishing.mdx | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/cli/features/package-publishing.mdx b/docs/cli/features/package-publishing.mdx index 15ba6fcee..1e226cd36 100644 --- a/docs/cli/features/package-publishing.mdx +++ b/docs/cli/features/package-publishing.mdx @@ -149,6 +149,43 @@ If a pack uses `when`, set `min_nono_version` to a CLI release that supports pre If your pack ships a profile with an `install_as` name that used to be compiled into the CLI (e.g. `claude-code` was a preset until v0.43), you also need to register it in the migration table so users on older versions get a friendly auto-pull prompt instead of a "profile not found" error. See [PACK_PROVIDED_PROFILES](#registering-a-pack-provided-profile) below. +## Sideloading your profile to test it +You can install the profile via the `nono sideload` command as-if it were already in the nono registry. This allows for testing as it would take place after a `nono pull` command. However, this requires extra setup to enable nono sideloading for profile development purposes + +### Build the nono CLI with sideloading capability + +To proceed with sideloading, you must build a local copy of nono with this ability available. Sideloading is ONLY for development purposes and is not available with the production version of nono. Clone the nono cli source code, then run +```bash +make build-sideload +``` + +This produces `target/debug/nono` with the `sideload` feature enabled. + + + The sideload binary has all integrity protections disabled. Signature verification, digest checks, and credential validation are all skipped. Never use it outside local development. + + +### Sideload your pack + +```bash +./target/debug/nono sideload ./path-to-your-pack +``` + +The pack directory must contain a `package.json`. The CLI reads the name, namespace, and version from it, then installs the artifacts into the pack store exactly as `nono pull` would — minus verification. + +To test the pack, you must continue to use the sideload-aware version of nono + +```bash +./target/debug/nono run --profile -- +``` + +If you try to use the production nono CLI, the sideloaded pack will fail verification. + +When you are finished verifying your pack, you can run +```bash +./target/debug/nono remove package-name +``` + ## Step 3: Create the Pack in the Registry Create the pack in the registry UI first. You will need: From 4a420531a821d06d3ddda27bacf4ee79b692140d Mon Sep 17 00:00:00 2001 From: Anil Kulkarni Date: Sat, 20 Jun 2026 11:21:22 -0700 Subject: [PATCH 3/3] Fix the namespace to be external, not part of the name --- crates/nono-cli/src/cli.rs | 8 +++ crates/nono-cli/src/package_cmd.rs | 76 +++++++++++++++++++++++++--- crates/nono-cli/tests/sideload.rs | 81 ++++++++++++++++++++++++------ 3 files changed, 144 insertions(+), 21 deletions(-) diff --git a/crates/nono-cli/src/cli.rs b/crates/nono-cli/src/cli.rs index f0b9ab9e2..a8ce55c32 100644 --- a/crates/nono-cli/src/cli.rs +++ b/crates/nono-cli/src/cli.rs @@ -895,6 +895,14 @@ pub struct SideloadArgs { /// Path to the local pack directory (must contain package.json) pub path: PathBuf, + /// Namespace (registry owner) for the pack, e.g. `acme-corp`. + /// + /// If omitted, the namespace is inferred from the `origin` git remote of + /// the pack directory (the GitHub owner extracted from the remote URL). + /// Errors if the namespace cannot be determined from either source. + #[arg(long, value_name = "NAMESPACE", help_heading = "OPTIONS")] + pub namespace: Option, + /// Print help #[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")] pub help: Option, diff --git a/crates/nono-cli/src/package_cmd.rs b/crates/nono-cli/src/package_cmd.rs index f878ce7f6..bcb35636f 100644 --- a/crates/nono-cli/src/package_cmd.rs +++ b/crates/nono-cli/src/package_cmd.rs @@ -242,8 +242,23 @@ pub fn run_sideload(args: crate::cli::SideloadArgs) -> Result<()> { let manifest: PackageManifest = serde_json::from_slice(&manifest_bytes) .map_err(|e| NonoError::PackageInstall(format!("failed to parse package.json: {e}")))?; - // Derive namespace/name from manifest name field (expected "namespace/name"). - let package_ref = derive_package_ref_from_manifest(&manifest)?; + // Resolve the namespace: explicit flag > git remote inference > error. + let namespace = if let Some(ns) = args.namespace.filter(|s| !s.is_empty()) { + ns + } else if let Some(ns) = infer_namespace_from_git_remote(&src_path) { + eprintln!("sideload: inferred namespace '{ns}' from origin git remote"); + ns + } else { + return Err(NonoError::PackageInstall( + "cannot determine namespace: no --namespace flag provided and no \ + 'origin' git remote found in the pack directory. \ + Re-run with: nono-sideload sideload --namespace " + .to_string(), + )); + }; + + // Derive the PackageRef from the bare manifest name + resolved namespace. + let package_ref = derive_package_ref_from_manifest(&manifest, &namespace)?; let version = manifest .version .clone() @@ -307,11 +322,60 @@ pub fn run_sideload(args: crate::cli::SideloadArgs) -> Result<()> { Ok(()) } -/// Derive a `PackageRef` from a `PackageManifest`. The manifest's `name` field -/// must be in `namespace/name` form (e.g. `"acme/my-pack"`). +/// Derive a `PackageRef` from a `PackageManifest` and an explicit namespace. +/// +/// `manifest.name` is treated as a bare pack name (e.g. `"python"`). The +/// namespace is supplied externally — either via `--namespace` on the CLI or +/// inferred from the `origin` git remote of the pack directory. +/// +/// This mirrors how `agent-sign` works: `package-name` and `package-namespace` +/// are separate inputs; `package.json`'s `name` field is never parsed for the +/// namespace. #[cfg(feature = "sideload")] -fn derive_package_ref_from_manifest(manifest: &PackageManifest) -> Result { - package::parse_package_ref(&manifest.name) +fn derive_package_ref_from_manifest( + manifest: &PackageManifest, + namespace: &str, +) -> Result { + package::parse_package_ref(&format!("{namespace}/{}", manifest.name)) +} + +/// Infer the registry namespace from the `origin` git remote of `dir`. +/// +/// Shells out to `git remote get-url origin` and extracts the GitHub owner +/// from either SSH (`git@github.com:owner/repo.git`) or HTTPS +/// (`https://github.com/owner/repo.git`) remote URLs. +/// +/// Returns `None` if git is unavailable, the directory is not a git repo, +/// there is no `origin` remote, or the URL cannot be parsed. +#[cfg(feature = "sideload")] +fn infer_namespace_from_git_remote(dir: &std::path::Path) -> Option { + let output = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(dir) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let url = std::str::from_utf8(&output.stdout).ok()?.trim(); + + // SSH: git@github.com:owner/repo.git + if let Some(rest) = url.strip_prefix("git@") { + let path = rest.split_once(':')?.1; + return path.split('/').next().map(|s| s.to_string()); + } + + // HTTPS: https://github.com/owner/repo.git or http://... + if url.starts_with("https://") || url.starts_with("http://") { + let without_scheme = url.split_once("://")?.1; + // skip host, take first path segment + let path = without_scheme.split_once('/')?.1; + return path.split('/').next().map(|s| s.to_string()); + } + + None } /// Represents a locally-sourced artifact (no download URL, digest computed diff --git a/crates/nono-cli/tests/sideload.rs b/crates/nono-cli/tests/sideload.rs index 995a0d4d5..e70409446 100644 --- a/crates/nono-cli/tests/sideload.rs +++ b/crates/nono-cli/tests/sideload.rs @@ -126,11 +126,12 @@ mod sideload_enabled { } /// Write a minimal valid pack fixture to `dir`. - /// The manifest `name` field must be `namespace/name` for `nono sideload`. + /// The manifest `name` field is a bare name (e.g. `"my-pack"`); the + /// namespace is supplied separately via `--namespace` when sideloading. fn write_fixture_pack(dir: &Path, namespace: &str, name: &str, version: &str) { let manifest = serde_json::json!({ "schema_version": 1, - "name": format!("{namespace}/{name}"), + "name": name, "version": version, "description": "test fixture pack", "artifacts": [ @@ -202,7 +203,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "my-pack", "0.1.0"); let (_, stderr, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok, "sideload failed:\n{stderr}"); @@ -244,7 +250,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "replaceable", "0.1.0"); let (_, _, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok); @@ -252,7 +263,12 @@ mod sideload_enabled { // Bump version, sideload again. write_fixture_pack(src.path(), "acme", "replaceable", "0.2.0"); let (_, stderr, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok, "re-sideload failed:\n{stderr}"); @@ -277,7 +293,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "listed", "1.0.0"); let (_, _, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok); @@ -303,7 +324,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "skipped", "1.0.0"); let (_, _, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok); @@ -329,7 +355,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "outdated-test", "1.0.0"); let (_, _, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok); @@ -354,7 +385,7 @@ mod sideload_enabled { // Write a manifest with an absolute path in the artifact list. let manifest = serde_json::json!({ "schema_version": 1, - "name": "acme/evil-abs", + "name": "evil-abs", "version": "1.0.0", "description": "adversarial fixture", "artifacts": [ @@ -372,7 +403,12 @@ mod sideload_enabled { .expect("write package.json"); let (_, stderr, ok) = run_nono( - &["sideload", src.path().to_str().expect("path")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path"), + ], cfg.path(), ); assert!(!ok, "sideload must fail for absolute artifact path"); @@ -391,7 +427,7 @@ mod sideload_enabled { let manifest = serde_json::json!({ "schema_version": 1, - "name": "acme/evil-dotdot", + "name": "evil-dotdot", "version": "1.0.0", "description": "adversarial fixture", "artifacts": [ @@ -409,7 +445,12 @@ mod sideload_enabled { .expect("write package.json"); let (_, stderr, ok) = run_nono( - &["sideload", src.path().to_str().expect("path")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path"), + ], cfg.path(), ); assert!(!ok, "sideload must fail for '..'-containing artifact path"); @@ -429,7 +470,7 @@ mod sideload_enabled { let manifest = serde_json::json!({ "schema_version": 1, - "name": "acme/evil-profile", + "name": "evil-profile", "version": "1.0.0", "description": "adversarial fixture", "artifacts": [ @@ -448,7 +489,12 @@ mod sideload_enabled { .expect("write package.json"); let (_, stderr, ok) = run_nono( - &["sideload", src.path().to_str().expect("path")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path"), + ], cfg.path(), ); assert!(!ok, "sideload must fail for '..'-profile artifact path"); @@ -467,7 +513,12 @@ mod sideload_enabled { write_fixture_pack(src.path(), "acme", "removable", "1.0.0"); let (_, _, ok) = run_nono( - &["sideload", src.path().to_str().expect("path to str")], + &[ + "sideload", + "--namespace", + "acme", + src.path().to_str().expect("path to str"), + ], cfg.path(), ); assert!(ok);