Skip to content
Draft
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
3 changes: 3 additions & 0 deletions crates/nono-cli/src/app_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ fn dispatch_command(
Commands::Outdated(args) => {
run_command_with_update(update_handle, silent, || package_cmd::run_outdated(args))
}
Commands::Pack(args) => {
run_command_with_update(update_handle, silent, || package_cmd::run_pack(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),
Expand Down
79 changes: 79 additions & 0 deletions crates/nono-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
pack Pack authoring/publishing utilities

\x1b[1mPOLICY & PROFILES\x1b[0m
policy [deprecated] Use 'nono profile' instead
Expand Down Expand Up @@ -668,6 +669,21 @@ IN-BAND DETACH:
")]
Outdated(OutdatedArgs),

/// Pack authoring/publishing utilities
#[command(help_template = "\
{about}

\x1b[1mUSAGE\x1b[0m
nono pack <command>

{all-args}
{after-help}")]
#[command(after_help = "\x1b[1mEXAMPLES\x1b[0m
nono pack publish-static my-org/my-pack --pack-dir ./dist --out ./registry
# Emit a static registry tree for nginx
")]
Pack(PackCmdArgs),

/// Generate shell completion scripts
#[command(name = "completion")]
#[command(help_template = "\
Expand Down Expand Up @@ -718,6 +734,62 @@ pub struct PullArgs {
#[arg(long, help_heading = "OPTIONS")]
pub init: bool,

/// Skip Sigstore signature verification (internal/air-gapped registry).
/// Integrity (SHA-256) is still checked; TLS is unaffected. Can also be
/// set fleet-wide via `[registry] verify = false` or NONO_REGISTRY_INSECURE.
#[arg(long, help_heading = "OPTIONS")]
pub insecure: bool,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
}

#[derive(Parser, Debug)]
#[command(disable_help_flag = true)]
pub struct PackCmdArgs {
#[command(subcommand)]
pub command: PackCommands,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
}

#[derive(Subcommand, Debug)]
pub enum PackCommands {
/// Generate a static registry tree (servable by nginx) for a built pack
PublishStatic(PackPublishStaticArgs),
}

#[derive(Parser, Debug)]
#[command(disable_help_flag = true)]
pub struct PackPublishStaticArgs {
/// Package reference (<namespace>/<name>)
pub package_ref: String,

/// Directory holding the built pack (package.json + artifact files)
#[arg(
long,
value_name = "DIR",
default_value = ".",
help_heading = "OPTIONS"
)]
pub pack_dir: PathBuf,

/// Output directory: the static registry docroot to write into
#[arg(long, value_name = "DIR", help_heading = "OPTIONS")]
pub out: PathBuf,

/// URL prefix prepended to artifact download URLs (e.g. "/nono"). Use when
/// the registry is served under a sub-path rather than the host root.
#[arg(long, value_name = "PATH", help_heading = "OPTIONS")]
pub base_path: Option<String>,

/// Override the version (defaults to `version` from package.json)
#[arg(long, value_name = "VERSION", help_heading = "OPTIONS")]
pub version: Option<String>,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
Expand Down Expand Up @@ -766,6 +838,12 @@ pub struct UpdateArgs {
#[arg(long, help_heading = "OPTIONS")]
pub force: bool,

/// Skip Sigstore signature verification (internal/air-gapped registry).
/// Integrity (SHA-256) is still checked; TLS is unaffected. Can also be
/// set fleet-wide via `[registry] verify = false` or NONO_REGISTRY_INSECURE.
#[arg(long, help_heading = "OPTIONS")]
pub insecure: bool,

/// Print help
#[arg(long, short = 'h', action = clap::ArgAction::Help, help_heading = "OPTIONS")]
pub help: Option<bool>,
Expand Down Expand Up @@ -4403,6 +4481,7 @@ mod tests {
"remove",
"update",
"outdated",
"pack",
"pin",
"unpin",
"search",
Expand Down
1 change: 1 addition & 0 deletions crates/nono-cli/src/cli_bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ fn cli_verbosity(cli: &Cli) -> u8 {
| Commands::Pin(_)
| Commands::Unpin(_)
| Commands::Outdated(_)
| Commands::Pack(_)
| Commands::OpenUrlHelper(_)
| Commands::PackUpdateHintHelper(_)
| Commands::Completions(_) => 0,
Expand Down
30 changes: 30 additions & 0 deletions crates/nono-cli/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub struct UserConfig {
#[serde(default)]
pub updates: UpdateSettings,
#[serde(default)]
pub registry: RegistrySettings,
#[serde(default)]
pub ui: UiSettings,
#[serde(default)]
pub redaction: RedactionSettings,
Expand Down Expand Up @@ -292,6 +294,34 @@ impl Default for UpdateSettings {
}
}

/// Pack registry settings.
///
/// Lets a fleet point `nono pull`/`nono update` at an internal registry and
/// (for air-gapped, internal-trust deployments) disable Sigstore signature
/// verification via a single MDM-managed `config.toml`. `verify` defaults to
/// `true` (fail-secure): signature checks stay on unless explicitly disabled.
/// Disabling `verify` turns off signature verification only — it does NOT
/// disable TLS host verification.
#[derive(Debug, Clone, Deserialize)]
pub struct RegistrySettings {
/// Default registry base URL. Lower precedence than `--registry` and the
/// `NONO_REGISTRY` env var; higher than the compiled-in default.
#[serde(default)]
pub url: Option<String>,
/// Whether to verify pack signatures (default: true).
#[serde(default = "default_true")]
pub verify: bool,
}

impl Default for RegistrySettings {
fn default() -> Self {
Self {
url: None,
verify: default_true(),
}
}
}

fn default_max_sessions() -> usize {
10
}
Expand Down
13 changes: 13 additions & 0 deletions crates/nono-cli/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ pub fn check_and_run(profile_name: &str) -> Result<MigrationOutcome> {
let chosen = if let Some(pack) = official_pack_for(profile_name) {
pack.as_provider()
} else {
// The provider lookup is a network call to the registry's
// `/api/v1/profiles/<name>/providers` endpoint. A static
// (air-gapped/internal) registry does not serve it, so skip the lookup
// when signature verification is disabled — the in-tree
// `official_pack_for` path above still works for known packs.
let verify = match crate::config::user::load_user_config() {
Ok(Some(config)) => config.registry.verify,
_ => true,
};
if !verify {
return Ok(MigrationOutcome::NotApplicable);
}
match fetch_providers(profile_name) {
Ok(p) if p.is_empty() => return Ok(MigrationOutcome::NotApplicable),
Ok(mut p) => p.remove(0),
Expand Down Expand Up @@ -301,6 +313,7 @@ fn run_pull(pack_ref: &str) -> Result<()> {
// re-install so the files actually exist before we retry.
force: true,
init: false,
insecure: false,
help: None,
},
PullReason::Migration,
Expand Down
6 changes: 5 additions & 1 deletion crates/nono-cli/src/pack_update_hint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ fn is_opted_out() -> bool {
return true;
}
match crate::config::user::load_user_config() {
Ok(Some(config)) => !config.updates.check,
// Also opt out when signature verification is disabled: that is the
// air-gapped / internal-registry posture, where the static registry
// does not serve the status endpoint and a background hint check would
// just time out on an unreachable host.
Ok(Some(config)) => !config.updates.check || !config.registry.verify,
_ => false,
}
}
Expand Down
40 changes: 39 additions & 1 deletion crates/nono-cli/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,31 @@ pub struct ProfileProvidersResponse {
pub providers: Vec<ProfileProvider>,
}

/// Signer identity recorded in the lockfile for packs installed without
/// signature verification (air-gapped / internal-registry mode). Pull and
/// run-time code key off this sentinel: the SHA-256 integrity checks still
/// run, but Sigstore bundle verification is skipped. Distinct from the
/// `keyed:`/`https://github.com/...` identities used by signed packs.
pub const UNSIGNED_SIGNER_IDENTITY: &str = "unsigned:internal-registry";

fn default_scan_passed() -> bool {
true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullResponse {
pub namespace: String,
pub name: String,
pub version: String,
pub provenance: PullProvenance,
// Optional so a static registry can omit it (unsigned packs). When present
// it carries the signed-pack provenance recorded at publish time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<PullProvenance>,
pub artifacts: Vec<PullArtifact>,
// Empty for unsigned packs served by a static registry (no bundle).
#[serde(default)]
pub bundle_url: String,
#[serde(default = "default_scan_passed")]
pub scan_passed: bool,
}

Expand All @@ -268,6 +285,7 @@ pub struct PullProvenance {
pub struct PullArtifact {
pub filename: String,
pub sha256_digest: String,
#[serde(default)]
pub size_bytes: i64,
pub download_url: String,
}
Expand Down Expand Up @@ -389,6 +407,26 @@ mod tests {
assert_eq!(parsed.version.as_deref(), Some("1.2.3"));
}

#[test]
fn pull_response_deserializes_without_provenance_or_bundle() {
// The minimal shape a static (unsigned) registry emits: no provenance,
// no bundle_url, no scan_passed, and artifacts without size_bytes.
let json = r#"{
"namespace": "acme",
"name": "widget",
"version": "1.0.0",
"artifacts": [
{ "filename": "package.json", "sha256_digest": "abc", "download_url": "/files/acme/widget/1.0.0/package.json" }
]
}"#;
let pull: PullResponse = serde_json::from_str(json).expect("deserialize minimal pull");
assert!(pull.provenance.is_none());
assert!(pull.bundle_url.is_empty());
assert!(pull.scan_passed, "scan_passed should default to true");
assert_eq!(pull.artifacts.len(), 1);
assert_eq!(pull.artifacts[0].size_bytes, 0);
}

#[test]
fn rejects_invalid_package_ref() {
let err = parse_package_ref("broken").expect_err("must fail");
Expand Down
Loading