diff --git a/.apm/architecture/owners/contracts-tooling.json b/.apm/architecture/owners/contracts-tooling.json index 065e3677d3..91ff94c760 100644 --- a/.apm/architecture/owners/contracts-tooling.json +++ b/.apm/architecture/owners/contracts-tooling.json @@ -90,10 +90,10 @@ }, { "id": "deterministic-atomic-text-writes", - "decision": "Deterministic UTF-8 text writes and atomic replacement", - "owner": "utils/atomic_io.py (write_text_lf, atomic_write_text)", + "decision": "Deterministic UTF-8 text writes, atomic replacement, and exclusive directory publication", + "owner": "utils/atomic_io.py (write_text_lf, atomic_write_text, publish_directory_noreplace)", "selectors": ["src/apm_cli/utils/atomic_io.py"], - "guards": ["contracts-tooling-project-yaml-write-delegation"] + "guards": ["contracts-tooling-project-yaml-write-delegation", "marketplace-integrations-exclusive-directory-publication"] }, { "id": "read-only-lockfile-path", diff --git a/.apm/architecture/owners/marketplace-plugins.json b/.apm/architecture/owners/marketplace-plugins.json index e64ab21118..385c0a736a 100644 --- a/.apm/architecture/owners/marketplace-plugins.json +++ b/.apm/architecture/owners/marketplace-plugins.json @@ -1,6 +1,16 @@ { "version": 1, "owners": [ + { + "id": "source-package-resources", + "decision": "Working-draft resource selection and non-activating APM source-envelope admission", + "owner": "models/package_resources.py (parse_resource_roots, collect_package_resources) + bundle/source_package.py (require_resource_pack_mode, reject_source_deployment, restore_source_package)", + "selectors": [ + "src/apm_cli/models/package_resources.py", + "src/apm_cli/bundle/source_package.py" + ], + "guards": ["marketplace-integrations-source-resources"] + }, { "id": "marketplace-tag-pattern", "decision": "Marketplace tag-pattern validation and expansion", diff --git a/docs/src/content/docs/producer/pack-a-bundle.md b/docs/src/content/docs/producer/pack-a-bundle.md index ab35f1ea0d..5dac04bbaf 100644 --- a/docs/src/content/docs/producer/pack-a-bundle.md +++ b/docs/src/content/docs/producer/pack-a-bundle.md @@ -73,6 +73,77 @@ apm pack --archive -o ./dist # -> ./dist/my-pkg-.zip ``` +## Independent resources (experimental) + +For package-owned contracts, checks, or other data that must not become +automatically activated primitives, use the working-draft `resources` field: + +```yaml +name: software-factory +version: "1.0.0" +resources: + - contracts + - checks +``` + +Keep an existing, supported `apm.lock.yaml` beside `apm.yml`, then run: + +```bash +apm pack --format apm --source --archive -o ./dist +apm unpack --source ./dist/software-factory-1.0.0.zip -o ./acquired +``` + +`acquired` must not exist, even as an empty directory. Source mode copies +the original manifest, lockfile, and selected resource files byte-for-byte. +It never runs scripts, checks, hooks, dependency installation, or compilation; +it also suppresses marketplace and plugin-manifest output. Choose an isolated +acquisition directory, not a consumer workspace. Configuration in the restored +manifest remains inert until a separate command explicitly uses it. + +Each resource entry is a nonempty, package-relative directory, recursively +selected without globs. Use POSIX paths without a leading `./` or trailing +slash; nested roots such as `factory/contracts` work. Roots cannot overlap +or alias each other by case. Files and directories must be regular, contained, +and unambiguous across platforms; symlinks and special files fail. +Hidden paths, `apm.yml`, `apm.lock`, `apm.lock.yaml`, `plugin.json`, `mcp.json`, +`apm_modules`, `node_modules`, `__pycache__`, `build`, and `dist` are rejected +anywhere in selected content. Do not select `.` or a broad parent containing +metadata, secrets, or caches. APM does not perform secret detection on arbitrary +resource bytes; the author is responsible for the explicitly selected content. + +The existing APM envelope contains an integrity lockfile with `pack.source: true` +and `pack.bundle_files` SHA-256 entries. Its `package/` directory holds the exact +author files, including the original lockfile; restoration removes only this +envelope prefix. Hashes detect corruption, not publisher authenticity: obtain +the archive and any external checksum from a trusted source. Missing, changed, +extra, ambiguous, or unsupported-version metadata fails before restoration. +Final staged bytes are checked against the original envelope hashes before +publication, not against hashes recomputed from potentially changed input. +Metadata documents are limited to 4 MiB each; the shared asset inventory allows +at most 10,000 budgeted entries and 512 MiB in aggregate. +Portable paths are limited to 4,096 UTF-8 bytes and 128 segments. +Ordinary file permissions are copied where the host supports them; set-ID and +sticky bits are rejected. SHA-256 entries attest file bytes, not permissions, +ownership, or timestamps. Use an externally trusted archive checksum when those +archive metadata values are part of your verification policy. + +Directory output and `--archive-format tar.gz` are also supported. `--dry-run` +performs the same admission and integrity checks without writing. `--force` and +`--skip-verify` cannot bypass source verification, and occupied outputs are +never replaced. +Directory publication requires native atomic no-replace support; unavailable +platform or filesystem capabilities fail closed. A destination created while +staging is also left untouched. + +This route packages only the author's declared resources, not dependency +resources or installed primitives. It adds no remote acquisition operation. +`apm install` and ordinary `apm unpack` reject marked source envelopes. +Renaming the marker lockfile to legacy `apm.lock` does not enable deployment. +Ordinary pack and plugin formats reject manifests declaring resources rather +than silently dropping them; `includes` remains primitive selection. +This extension is not normative OpenAPM: omit `$schema` to select the working +draft. Existing releases without `--source` support cannot use this route. + ## The plugin.json contract `plugin.json` is the bundle's identity card. Only `name` is required. APM diff --git a/docs/src/content/docs/reference/cli/pack.md b/docs/src/content/docs/reference/cli/pack.md index 434fc6ed93..631204d2a9 100644 --- a/docs/src/content/docs/reference/cli/pack.md +++ b/docs/src/content/docs/reference/cli/pack.md @@ -26,8 +26,16 @@ Bundles are target-agnostic. The consumer's project decides where files land at ## Options +**Experimental source mode:** `--format apm --source` packages only declared +`resources` plus the original `apm.yml` and `apm.lock.yaml`, without deployment +or marketplace output. Restore with `apm unpack --source`, not `apm install`. +See [independent resources](../../../producer/pack-a-bundle/#independent-resources-experimental) +for selection, integrity, and safety rules. Declared resources cause ordinary +pack and plugin formats to fail rather than silently omit them. + | Flag | Default | Description | |---|---|---| +| `--source` | off | Experimental non-activating source package. Requires `--format apm`, working-draft `resources`, and an existing supported `apm.lock.yaml`. Only archive/output/dry-run/JSON/verbose options combine with this mode; outputs must not exist. | | `--claude-plugin` | on (no-flag default) | Select the Claude Code plugin bundle: `plugin.json` plus plugin-native subdirs (`agents/`, `skills/`, `commands/`, `instructions/`, `hooks/`). This is what `apm pack` produces with zero flags. | | `--format plugin\|agent-plugin\|claude\|claude-plugin\|apm` | `claude-plugin` | Bundle format selector. `agent-plugin` is the sole opt-in for the portable Agent Plugins v1 bundle. `plugin` is a compatibility alias for the Claude Code plugin bundle, not for `agent-plugin`. `claude` and `claude-plugin` also select the Claude Code plugin bundle (the no-flag default). `apm` emits the legacy APM bundle layout, kept for tooling that still consumes it (e.g. `microsoft/apm-action@v1` restore mode). Passing more than one selector (`--claude-plugin`, `--format`) is a usage error. | | `--archive` | off | Produce a `.zip` archive instead of a directory (previous default: `.tar.gz`; use `--archive-format tar.gz` for legacy CI pipelines). Bundle only. | diff --git a/docs/src/content/docs/reference/cli/unpack.md b/docs/src/content/docs/reference/cli/unpack.md index 33f5268533..5642876425 100644 --- a/docs/src/content/docs/reference/cli/unpack.md +++ b/docs/src/content/docs/reference/cli/unpack.md @@ -1,12 +1,12 @@ --- title: apm unpack -description: Extract an APM bundle into a project directory with verification and security scanning. +description: Restore inert source packages, or extract legacy deployment bundles. sidebar: order: 18 --- :::caution[Deprecated] -`apm unpack` is deprecated and will be removed in a future release. For plugin-format bundles, prefer [`apm install `](../install/) -- it shares the same air-gapped path, integrates with target resolution, and records deployed files in the project lockfile. `apm unpack` remains the only deploy path for legacy `--format apm` tarballs (see [Behavior](#behavior)). +Deployment extraction without `--source` is deprecated. For plugin-format bundles, prefer [`apm install `](../install/) -- it shares the same air-gapped path, integrates with target resolution, and records deployed files in the project lockfile. The experimental `--source` mode below is a separate, non-activating restoration route. ::: ## Synopsis @@ -17,6 +17,28 @@ apm unpack BUNDLE_PATH [OPTIONS] ## Description +### Source restoration (experimental) + +```bash +apm unpack --source ./software-factory-1.0.0.zip -o ./acquired +``` + +Accepts a directory, ZIP, or tar.gz produced by `apm pack --format apm --source`. +The output directory must not exist, even if empty. Restores the exact author +`apm.yml`, `apm.lock.yaml`, and resource paths and bytes after mandatory SHA-256 +verification. Does not install dependencies, activate primitives, run checks or +hooks, compile, or modify another workspace. No network acquisition is added. +Missing, corrupt, ambiguous, or unsafe content fails before any output files +are published. `--dry-run` validates without writing; `--skip-verify` and +`--force` are rejected. Hashes verify integrity, not publisher authenticity. +See [source-package rules](../../../producer/pack-a-bundle/#independent-resources-experimental). + +Marked source packages cannot be passed to `apm install` or unpacked without +`--source`. The remaining deployment behavior below applies only without +`--source`. + +### Legacy deployment extraction + `apm unpack` extracts an APM bundle (a `.zip` or legacy `.tar.gz` archive, or an already-unpacked bundle directory) into a target project. It runs the built-in security scan against the bundle contents before writing any files, and -- unless `--skip-verify` is set -- checks that every entry in the bundle's `apm.lock.yaml` `deployed_files` list is actually present in the archive. Extraction is **additive-only**: only files listed in the bundle's lockfile are written. Existing project files at colliding paths are overwritten by the bundle version. Files outside the bundle's manifest are never touched, and the bundle's `apm.lock.yaml` is treated as metadata -- it is not copied into the output directory. @@ -38,6 +60,7 @@ governs dependency installs. | Flag | Default | Description | |---|---|---| +| `--source` | off | Experimental, verified, non-activating restoration into a new directory. Cannot combine with `--force` or `--skip-verify`. | | `-o`, `--output PATH` | `.` | Target project directory. Created if it does not exist. | | `--skip-verify` | off | Skip the bundle completeness check against the bundle's `apm.lock.yaml`. Useful for partial bundles. | | `--dry-run` | off | List files that would be unpacked without writing anything. | diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index 3aeee8a07c..5d45f4bc35 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -53,6 +53,7 @@ target: > type: scripts: > includes: > +resources: > # experimental; working draft only registries: & {default?: }> dependencies: apm: > @@ -346,6 +347,20 @@ For full client semantics - auth, lockfile fields, and routing rules - see the [ --- +### 3.12. `resources` (experimental) + +An optional, non-empty list of dedicated package-relative **directory** roots +for independent data, such as `resources: [contracts, checks]`. It is available +only in the working draft (omit `$schema`), not normative OpenAPM. +Unlike `includes`, it does not select deployable primitives or authorize +execution. Roots must be nonoverlapping, portable POSIX paths with no globs, +traversal, `.` root, hidden paths, or metadata/cache directories. + +Only `apm pack --format apm --source` represents these resources; ordinary +pack and both plugin formats reject them. `apm unpack --source` restores their +exact bytes and author metadata into a new directory, without activation. +See [selection and restoration rules](../../producer/pack-a-bundle/#independent-resources-experimental). + ## 4. Dependencies | | | diff --git a/packages/apm-guide/.apm/skills/apm-usage/commands.md b/packages/apm-guide/.apm/skills/apm-usage/commands.md index 58c7eb3dae..a01b8f44a0 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/commands.md +++ b/packages/apm-guide/.apm/skills/apm-usage/commands.md @@ -335,6 +335,17 @@ Lifecycle scripts fire on six events: `pre-install`, `post-install`, `pre-update ## Distribution +Experimental source packages use `apm pack --format apm --source [--archive]` +with working-draft `resources` directory roots and an existing `apm.lock.yaml`. +Only output/archive/dry-run/JSON/verbose options combine with source pack. +Restore with `apm unpack --source BUNDLE -o NEW_DIRECTORY`; the directory must +not exist. Both commands preserve exact manifest/lock/resource bytes without +installation, compilation, hook/check/script execution, or marketplace output. +`--dry-run` still validates. `--force` and `--skip-verify` cannot bypass source +integrity checks. This local route adds no remote acquisition. Plugin/ordinary +pack rejects declared resources; install and legacy unpack reject source +envelopes. See [resource selection rules](package-authoring.md#independent-resources-experimental-working-draft). + | Command | Purpose | Key flags | |---------|---------|-----------| | `apm pack` | Build distributable artifacts (bundle and/or marketplace.json -- driven by `apm.yml`). A `dependencies:` mapping, including `dependencies: {}`, produces a bundle of local package content; omitted or null `dependencies:` does not. Default output (no format flag) is a Claude Code plugin directory. Pass `--format agent-plugin` to opt into a portable Agent Plugins v1 bundle instead -- strict portable core only (root `plugin.json`, `skills/`, root `mcp.json` written even when empty; no `agents/`, `commands/`, `instructions/`, `extensions/`, `hooks/`, or LSP payload). That bundle build fails before any output is written if the source project has non-portable agents/commands/instructions/extensions/hooks/LSP, naming the surfaces and pointing to `--format claude-plugin` (and to configuring LSP in the target directly, since neither pack format carries it). A packed Agent Plugin installs through the declarative route: declare it as a dependency in `apm.yml` and run `apm install --target copilot`, which keeps the unit whole under `apm_modules/` and registers it without locating or executing Copilot; stable Copilot CLI 1.0.81 or newer is required when loading the projection. The imperative local-bundle route still fails closed for Agent Plugin bundles. Bundles are **target-agnostic**: `pack.target` is recorded in every bundle for diagnostic purposes (typically `"all"` for target-agnostic packs, or the project's detected target) and is not authoritative at install time; `pack.bundle_files` (path -> sha256) drives integrity verification. The consumer's project decides where files land. Dependency content is packed **exclusively** from lockfile-attested `deployed_files` (in every bundle format); the `apm_modules` cache is never packed. Each file is verified against its `deployed_file_hashes` SHA-256 before inclusion, so a file tampered after `apm install` (hash mismatch) or deleted (missing on disk) fails the pack with a message pointing at `apm install`; files with no recorded hash (older lockfiles) pack unverified. Dependency hooks-config / MCP-config is not attested, so it is not packed -- `apm pack` warns (`[!]`) and names the dependency (first-party root hooks/MCP are still packed). Marketplace-publishing projects (`marketplace:` block, no `dependencies:`) no longer emit the misleading "No plugin.json found" warning; after a successful build, a vendor-neutral catalog of artifact paths is appended together with a single docs pointer (`producer/publish-to-a-marketplace/#consume-from-any-assistant`) listing per-assistant install paths. Release-time gates `--check-versions` and `--check-clean` are opt-in and exit non-zero on misalignment / drift (codes 3 and 4 respectively) so release pipelines can fail fast; `--check-clean` is always read-only and never writes pack outputs. The version gate reads a local package's `apm.yml` first; Plugin collections without `apm.yml` use `plugin.json`'s `version`. Invalid or versionless `apm.yml` fails closed, and the fallback likewise rejects malformed or non-object JSON and a missing or blank version. When `apm.yml` declares `target: claude` or `target: copilot` (or the plural `targets:` equivalent), `apm pack` also generates an ecosystem-specific `plugin.json`: `.claude-plugin/plugin.json` for Claude (includes `mcpServers` from `.mcp.json` if present) and `.github/plugin/plugin.json` for Copilot (omits `mcpServers`). An existing file at the target path is preserved (a warning is emitted and the write is skipped) unless `--force` is passed; `--dry-run` prevents writes. Credential-bearing keys and secret-shaped values in `.mcp.json` are stripped recursively at any depth from the Claude manifest before writing, so a committed manifest never leaks secrets (see the apm pack reference, `reference/cli/pack/#credential-stripping-claude-mcpservers`). | `-o PATH`, `--archive` (produce a `.zip` archive instead of a directory; changed from `.tar.gz`), `--archive-format [zip\|tar.gz]` (default `zip`; use `tar.gz` for smaller legacy CI artifacts; only active with `--archive`), `--dry-run`, `--format [plugin\|agent-plugin\|claude\|claude-plugin\|apm]` (`agent-plugin` is the sole selector for the Agent Plugin bundle; `plugin` is a compatibility alias for the Claude plugin bundle, not for `agent-plugin`; `claude`/`claude-plugin` also select the Claude plugin bundle; `apm` selects the legacy APM layout; default `claude-plugin`), `--claude-plugin` (shortcut for `--format claude-plugin`; passing more than one of `--claude-plugin`/`--format` is a usage error), `--force`, `--offline`, `--include-prerelease`, `--marketplace=FORMATS`, `--marketplace-path FORMAT=PATH`, `--json`, `--check-versions` (release gate: per-package versions match `marketplace.versioning.strategy`; exit 3 on failure), `--check-clean` (read-only release gate: regenerate-and-diff against the effective marketplace path, including `--marketplace-path` overrides; never writes pack outputs; exit 4 on drift). `-t/--target` is **deprecated** (warn only). Exit codes: `0` success, `1` build/runtime error, `2` schema validation error, `3` `--check-versions` misalignment, `4` `--check-clean` drift. | diff --git a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md index 832575ee49..54ec92cc22 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md +++ b/packages/apm-guide/.apm/skills/apm-usage/package-authoring.md @@ -78,6 +78,38 @@ my-package/ resource2.md ``` +## Independent resources (experimental working draft) + +Use `resources: [contracts, checks]` for package-owned data that is not an +activated primitive. Entries select whole nonempty directories recursively, +not individual files or globs. Paths are package-relative POSIX paths without +`./`, trailing slashes, overlaps, case aliases, symlinks, or traversal. +Hidden paths and metadata/cache names (`apm.yml`, `apm.lock`, `apm.lock.yaml`, +`plugin.json`, `mcp.json`, `apm_modules`, `node_modules`, `__pycache__`, `build`, +`dist`) are forbidden anywhere in the selection. Select dedicated directories, +never `.` or broad parents containing secrets. Content is not secret-scanned. + +With the original `apm.yml` and a supported `apm.lock.yaml` present: + +```bash +apm pack --format apm --source --archive +apm unpack --source build/software-factory-1.0.0.zip -o acquired +``` + +`acquired` must not exist. Original manifest, lockfile, and resource bytes are +preserved exactly. The envelope's `pack.bundle_files` hashes the `package/` +payload; restoration removes that prefix only. Missing/corrupt/extra content +fails closed; neither `--force` nor `--skip-verify` bypasses verification. +Final staged bytes must match the original envelope hashes. Directory publication +requires native atomic no-replace support and fails closed if unavailable; +destinations created during staging are not replaced. +No scripts, checks, hooks, primitives, dependencies, or marketplace outputs are +activated. Directory and tar.gz forms also work; `--dry-run` is read-only. +There is no new remote acquisition or transitive resource flattening. +`apm install` and legacy unpack reject source envelopes; plugin/ordinary pack +reject declared resources instead of dropping them. Omit `$schema`: this is not +normative OpenAPM and requires a CLI version implementing `--source`. + ## Install-time discovery rules When `.apm/` exists, `apm pack` sources local primitives and hooks from diff --git a/scripts/architecture_linter/checks/marketplace_package_and_registration.py b/scripts/architecture_linter/checks/marketplace_package_and_registration.py index 8458605812..899233e756 100644 --- a/scripts/architecture_linter/checks/marketplace_package_and_registration.py +++ b/scripts/architecture_linter/checks/marketplace_package_and_registration.py @@ -428,7 +428,112 @@ def _check_copilot_ownership(provider: FactsProvider) -> tuple[Violation, ...]: return tuple(findings) +_RID_RESOURCES = "marketplace-integrations-source-resources" +_RESOURCE_OWNER = "src/apm_cli/models/package_resources.py" +_SOURCE_OWNER = "src/apm_cli/bundle/source_package.py" +_ATOMIC_OWNER = "src/apm_cli/utils/atomic_io.py" + + +def _check_source_resources(provider: FactsProvider) -> tuple[Violation, ...]: + """Keep selection, integrity, and non-activation admission on their owners.""" + inv = frozenset(provider.inventory) + findings: list[Violation] = [] + for owner, definitions in ( + (_RESOURCE_OWNER, ("parse_resource_roots", "collect_package_resources")), + (_ATOMIC_OWNER, ("publish_directory_noreplace",)), + ( + _SOURCE_OWNER, + ("require_resource_pack_mode", "reject_source_deployment", "restore_source_package"), + ), + ): + for name in definitions: + findings.extend( + _forbid_scan( + provider, + inv, + _RID_RESOURCES, + _src_python(provider, exclude={owner}), + re.compile(rf"^def {name}\("), + f"{name} must remain owned by {owner}", + exempt=False, + ) + ) + seams = { + _APM_PACKAGE: ("resources = parse_resource_roots(",), + _RESOURCE_OWNER: ( + "inventory.collect_component(directory)", + "validate_portable_relative_path(", + ), + _SOURCE_OWNER: ( + "collect_package_resources(", + "verify_bundle_integrity(root, metadata)", + 'destination, {"pack": {"bundle_files": expected_hashes}}', + "for name in (LOCKFILE_NAME, LEGACY_LOCKFILE_NAME):", + "publish_directory_noreplace(staged, destination)", + "publish_directory_noreplace(staged, output)", + "inventory.open_verified_asset(asset)", + "enrich_lockfile_for_pack(", + ), + "src/apm_cli/bundle/packer.py": ( + "pack_source_package(", + "require_resource_pack_mode(package)", + ), + "src/apm_cli/bundle/plugin_exporter.py": ("require_resource_pack_mode(package)",), + "src/apm_cli/bundle/agent_plugin_exporter.py": ("require_resource_pack_mode(package)",), + "src/apm_cli/bundle/unpacker.py": ( + "restore_source_package(", + "reject_source_deployment(source_dir)", + ), + "src/apm_cli/bundle/local_bundle.py": ( + "reject_source_deployment(path)", + "reject_source_deployment(extract_dir)", + ), + "src/apm_cli/models/format_detection.py": ("reject_source_deployment(package_path)",), + } + for path, needles in seams.items(): + findings.extend( + _require_subs( + provider, + inv, + _RID_RESOURCES, + path, + needles, + "Source-resource consumers must route through the canonical owner", + ) + ) + findings.extend( + _forbid_scan( + provider, + inv, + _RID_RESOURCES, + (_SOURCE_OWNER,), + re.compile(r"\.rename\(|os\.replace\("), + "Source-directory publication must use the atomic no-replace owner", + exempt=False, + ) + ) + findings.extend( + _forbid_scan( + provider, + inv, + _RID_RESOURCES, + (_SOURCE_OWNER, _RESOURCE_OWNER), + re.compile(r"subprocess|from .*install\.|from .*integration\.|from .*compilation\."), + "Source-package operations must not activate or integrate content", + exempt=False, + ) + ) + return tuple(findings) + + RULES: tuple[Rule, ...] = ( + Rule( + id=_RID_RESOURCES, + group=GROUP, + guard_ids=(_RID_RESOURCES, "marketplace-integrations-exclusive-directory-publication"), + description="Source-resource selection and non-activation stay on canonical owners.", + check=_check_source_resources, + ), Rule( id=_RID_CONSTRUCTION, group=GROUP, diff --git a/src/apm_cli/agent_plugins/assets.py b/src/apm_cli/agent_plugins/assets.py index 819e7fc909..9d4f67ca72 100644 --- a/src/apm_cli/agent_plugins/assets.py +++ b/src/apm_cli/agent_plugins/assets.py @@ -349,9 +349,13 @@ def _open_verified_asset( ): raise AssetInventoryError(f"asset {expected.path} no longer matches its inventory") digest = hashlib.sha256() + bytes_read = 0 while chunk := file_handle.read(_READ_CHUNK_BYTES): + bytes_read += len(chunk) + if bytes_read > expected.size: + raise AssetInventoryError(f"asset {expected.path} grew after inventory") digest.update(chunk) - if digest.hexdigest() != expected.sha256: + if bytes_read != expected.size or digest.hexdigest() != expected.sha256: raise AssetInventoryError(f"asset {expected.path} no longer matches its inventory") file_handle.seek(0) yield file_handle diff --git a/src/apm_cli/bundle/agent_plugin_exporter.py b/src/apm_cli/bundle/agent_plugin_exporter.py index 9c6655f992..1e2f87cf7d 100644 --- a/src/apm_cli/bundle/agent_plugin_exporter.py +++ b/src/apm_cli/bundle/agent_plugin_exporter.py @@ -313,6 +313,9 @@ def export_agent_plugin_bundle( apm_yml_path = project_root / "apm.yml" package = APMPackage.from_apm_yml(apm_yml_path) + from .source_package import require_resource_pack_mode + + require_resource_pack_mode(package) pkg_name = package.name pkg_version = package.version or "0.0.0" diff --git a/src/apm_cli/bundle/local_bundle.py b/src/apm_cli/bundle/local_bundle.py index 264cf61c87..3be9c1ce37 100644 --- a/src/apm_cli/bundle/local_bundle.py +++ b/src/apm_cli/bundle/local_bundle.py @@ -275,6 +275,12 @@ def _find_extracted_root(extract_dir: Path) -> Path | None: contents land under ``//``. Falls back to *extract_dir* itself if a top-level ``plugin.json`` is found. """ + from .source_package import reject_source_deployment + + reject_source_deployment(extract_dir) + for child in extract_dir.iterdir(): + if child.is_dir() and not child.is_symlink(): + reject_source_deployment(child) if (extract_dir / "plugin.json").is_file(): return extract_dir children = [p for p in extract_dir.iterdir() if p.is_dir()] @@ -310,7 +316,11 @@ def _extract_zip_bundle(path: Path) -> LocalBundleInfo | None: except ValueError: shutil.rmtree(temp_dir, ignore_errors=True) raise - bundle_root = _find_extracted_root(temp_dir) + try: + bundle_root = _find_extracted_root(temp_dir) + except ValueError: + shutil.rmtree(temp_dir, ignore_errors=True) + raise if bundle_root is None: shutil.rmtree(temp_dir, ignore_errors=True) return None @@ -343,6 +353,9 @@ def detect_local_bundle(path: Path) -> LocalBundleInfo | None: return None if path.is_dir(): + from .source_package import reject_source_deployment + + reject_source_deployment(path) if not (path / "plugin.json").is_file(): return None return _build_info( @@ -361,7 +374,11 @@ def detect_local_bundle(path: Path) -> LocalBundleInfo | None: except (ArchiveError, OSError): shutil.rmtree(temp_dir, ignore_errors=True) return None - bundle_root = _find_extracted_root(temp_dir) + try: + bundle_root = _find_extracted_root(temp_dir) + except ValueError: + shutil.rmtree(temp_dir, ignore_errors=True) + raise if bundle_root is None: shutil.rmtree(temp_dir, ignore_errors=True) return None diff --git a/src/apm_cli/bundle/lockfile_enrichment.py b/src/apm_cli/bundle/lockfile_enrichment.py index 373f169636..8a4433a866 100644 --- a/src/apm_cli/bundle/lockfile_enrichment.py +++ b/src/apm_cli/bundle/lockfile_enrichment.py @@ -187,6 +187,7 @@ def enrich_lockfile_for_pack( *, bundle_files: dict[str, str] | None = None, packed_at: str | None = None, + source: bool = False, ) -> str: """Create an enriched copy of the lockfile YAML with a ``pack:`` section. @@ -252,6 +253,8 @@ def enrich_lockfile_for_pack( packed_at if packed_at is not None else datetime.now(timezone.utc).isoformat() ), } + if source: + pack_meta["source"] = True if all_mappings: # Record the source prefixes that were remapped so consumers know the # bundle paths differ from the original lockfile. Use the canonical diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py index 433ce7b4aa..a51bf74ec2 100644 --- a/src/apm_cli/bundle/packer.py +++ b/src/apm_cli/bundle/packer.py @@ -41,6 +41,7 @@ def pack_bundle( dry_run: bool = False, force: bool = False, logger=None, + source: bool = False, ) -> PackResult: """Create a self-contained bundle from installed APM dependencies. @@ -68,6 +69,20 @@ def pack_bundle( ValueError: If deployed files referenced in the lockfile are missing on disk. """ bundle_format = coerce_bundle_format(fmt) + if source: + from .source_package import pack_source_package + + if bundle_format is not BundleFormat.APM: + raise ValueError("Source packages require 'apm pack --format apm --source'") + if force: + raise ValueError("--source cannot use --force; choose a new output path") + return pack_source_package( + project_root, + output_dir, + archive=archive, + archive_format=archive_format, + dry_run=dry_run, + ) if bundle_format is BundleFormat.AGENT_PLUGIN: from .agent_plugin_exporter import export_agent_plugin_bundle @@ -109,6 +124,9 @@ def pack_bundle( is_hybrid_root = apm_yml_path.exists() and skill_md_path.exists() try: package = APMPackage.from_apm_yml(apm_yml_path) + from .source_package import require_resource_pack_mode + + require_resource_pack_mode(package) pkg_name = package.name pkg_version = package.version or "0.0.0" from apm_cli.models.apm_package import package_target_selection diff --git a/src/apm_cli/bundle/plugin_exporter.py b/src/apm_cli/bundle/plugin_exporter.py index 7b1b5b1ea4..fd99c096b5 100644 --- a/src/apm_cli/bundle/plugin_exporter.py +++ b/src/apm_cli/bundle/plugin_exporter.py @@ -831,6 +831,9 @@ def export_plugin_bundle( # 2. Read apm.yml apm_yml_path = project_root / "apm.yml" package = APMPackage.from_apm_yml(apm_yml_path) + from .source_package import require_resource_pack_mode + + require_resource_pack_mode(package) pkg_name = package.name pkg_version = package.version or "0.0.0" diff --git a/src/apm_cli/bundle/source_package.py b/src/apm_cli/bundle/source_package.py new file mode 100644 index 0000000000..5098954471 --- /dev/null +++ b/src/apm_cli/bundle/source_package.py @@ -0,0 +1,272 @@ +"""Non-activating source-package envelope over existing APM bundle integrity.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from ..agent_plugins.assets import AssetInventory +from ..agent_plugins.ir import AgentPluginAsset +from ..deps.lockfile import ( + LEGACY_LOCKFILE_NAME, + LOCKFILE_NAME, + LockFile, + require_supported_lockfile_version, +) +from ..models.apm_package import APMPackage +from ..models.package_resources import collect_package_resources +from ..utils.archive import projected_archive_path, write_tar_archive, write_zip_archive +from ..utils.atomic_io import publish_directory_noreplace, write_text_lf +from ..utils.path_security import ( + ensure_path_within, + has_symlink_component, + source_permission_bits, + validate_portable_relative_path, +) +from ..utils.yaml_io import load_yaml, load_yaml_str +from .formats import BundleFormat +from .lockfile_enrichment import enrich_lockfile_for_pack + +if TYPE_CHECKING: + from .packer import PackResult + from .unpacker import UnpackResult + +_PAYLOAD = "package" +_METADATA_BYTES = 4 * 1024 * 1024 + + +def _read_mapping(inventory: AssetInventory, path: Path) -> tuple[dict[str, Any], bytes]: + _, payload = inventory.read_file(path, max_bytes=_METADATA_BYTES) + try: + data = load_yaml_str(payload.decode("utf-8"), reject_duplicate_keys=True) + except (UnicodeError, yaml.YAMLError) as exc: + raise ValueError(f"Invalid source-package metadata {path.name}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"Source-package metadata {path.name} must be a mapping") + return data, payload + + +def _source_inventory( + root: Path, +) -> tuple[APMPackage, AssetInventory, tuple[AgentPluginAsset, ...]]: + inventory = AssetInventory(root) + names = {entry.name for entry in inventory.list_component_candidates(root)} + if not {"apm.yml", LOCKFILE_NAME} <= names: + raise ValueError("Source packages require exact apm.yml and apm.lock.yaml metadata names") + manifest, _ = _read_mapping(inventory, root / "apm.yml") + package = APMPackage.from_mapping(manifest, package_path=root, create_config=False) + if not package.resources: + raise ValueError("Source packages require explicit 'resources:' directory roots in apm.yml") + lock_data, lock_bytes = _read_mapping(inventory, root / LOCKFILE_NAME) + require_supported_lockfile_version(lock_data) + LockFile.from_yaml(lock_bytes.decode("utf-8")) + assets = ( + inventory.collect_file(root / "apm.yml"), + inventory.collect_file(root / LOCKFILE_NAME), + *collect_package_resources(root, package.resources, inventory), + ) + for asset in assets: + source_permission_bits(asset.source.path.lstat().st_mode) + if has_symlink_component(root, root / asset.path, raise_on_error=True): + raise ValueError(f"Source-package symlink rejected: {asset.path}") + return package, inventory, assets + + +def require_resource_pack_mode(package: APMPackage, *, source: bool = False) -> None: + """Refuse silent resource loss in every deployment/plugin exporter.""" + if package.resources and not source: + raise ValueError( + "Declared resources are non-activating package content, not plugin primitives. " + "Use 'apm pack --format apm --source' to preserve them." + ) + + +def reject_source_deployment(root: Path) -> None: + """Block a marked source envelope before any deployable-format admission.""" + root = root.resolve() + # Admission only probes the marker. Do not apply source-only metadata + # size/duplicate-key restrictions to otherwise valid legacy packages. + for name in (LOCKFILE_NAME, LEGACY_LOCKFILE_NAME): + path = root / name + if not path.exists() and not path.is_symlink(): + continue + if path.is_symlink() or not path.is_file(): + raise ValueError("Bundle source marker metadata must be a regular file") + try: + data = load_yaml(path) + except yaml.YAMLError as exc: + raise ValueError(f"Cannot inspect bundle source marker: {exc}") from exc + pack = data.get("pack") if isinstance(data, dict) else None + if isinstance(pack, dict) and "source" in pack: + raise ValueError( + "Source packages cannot be deployed or installed. " + "Use 'apm unpack --source -o ' to restore inert bytes." + ) + + +def _require_new_destination(destination: Path) -> None: + if destination.exists() or destination.is_symlink(): + raise ValueError( + f"Source-package output already exists: {destination}. Choose a new directory or archive." + ) + if has_symlink_component(destination.parent, destination, raise_on_error=True): + raise ValueError(f"Source-package output is symlinked: {destination}") + + +def _copy_assets( + inventory: AssetInventory, + assets: tuple[AgentPluginAsset, ...], + destination: Path, + *, + expected_hashes: dict[str, str], +) -> None: + from .local_bundle import verify_bundle_integrity + + for asset in assets: + target = destination / asset.path + ensure_path_within(target, destination) + target.parent.mkdir(parents=True, exist_ok=True) + with inventory.open_verified_asset(asset) as source, target.open("xb") as output: + mode = source_permission_bits(os.fstat(source.fileno()).st_mode) + remaining = asset.size + while remaining: + chunk = source.read(min(remaining, 1024 * 1024)) + if not chunk: + raise ValueError(f"Source-package file changed while copying: {asset.path}") + output.write(chunk) + remaining -= len(chunk) + if source.read(1): + raise ValueError(f"Source-package file grew while copying: {asset.path}") + target.chmod(mode) + errors = verify_bundle_integrity(destination, {"pack": {"bundle_files": expected_hashes}}) + if errors: + raise ValueError("Source-package copy failed integrity verification: " + "; ".join(errors)) + + +def pack_source_package( + project_root: Path, + output_dir: Path, + *, + archive: bool, + archive_format: str, + dry_run: bool, +) -> PackResult: + """Pack exact author metadata and selected resources, with no deployment writes.""" + from .packer import PackResult + from .plugin_exporter import _sanitize_bundle_name + + if project_root.is_symlink(): + raise ValueError("Source-package root must not be a symlink") + root = project_root.resolve() + package, inventory, assets = _source_inventory(root) + name = _sanitize_bundle_name(f"{package.name}-{package.version}") + validate_portable_relative_path(name, context="source bundle name") + destination = ( + projected_archive_path(output_dir, name, archive_format) if archive else output_dir / name + ) + _require_new_destination(destination) + for resource in package.resources: + if destination.resolve().is_relative_to((root / resource).resolve()): + raise ValueError("Source-package output cannot be inside a selected resource directory") + result = PackResult( + bundle_path=destination, + files=[asset.path for asset in assets], + lockfile_enriched=True, + ) + if dry_run: + return result + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".apm-source-", dir=output_dir) as temporary: + staged = Path(temporary) / name + staged.mkdir() + payload_hashes = {asset.path: asset.sha256 for asset in assets} + _copy_assets(inventory, assets, staged / _PAYLOAD, expected_hashes=payload_hashes) + hashes = {f"{_PAYLOAD}/{path}": digest for path, digest in payload_hashes.items()} + write_text_lf( + staged / LOCKFILE_NAME, + enrich_lockfile_for_pack( + LockFile(), BundleFormat.APM, "all", bundle_files=hashes, source=True + ), + ) + # Revalidate staged bytes before publishing either representation. + _verify_source_package(staged) + if archive: + staged_archive = projected_archive_path(Path(temporary), name, archive_format) + writer = write_tar_archive if archive_format == "tar.gz" else write_zip_archive + writer(staged, staged_archive) + os.link(staged_archive, destination) + else: + publish_directory_noreplace(staged, destination) + return result + + +def _verify_source_package( + root: Path, +) -> tuple[dict[str, Any], AssetInventory, tuple[AgentPluginAsset, ...]]: + from .local_bundle import verify_bundle_integrity + + if root.is_symlink(): + raise ValueError("Source-package root must not be a symlink") + root = root.resolve() + envelope_inventory = AssetInventory(root) + envelope_assets = envelope_inventory.collect_component(root) + for asset in envelope_assets: + source_permission_bits(asset.source.path.lstat().st_mode) + validate_portable_relative_path(asset.path, context="source bundle path") + if has_symlink_component(root, root / asset.path, raise_on_error=True): + raise ValueError(f"Source-package symlink rejected: {asset.path}") + metadata, raw = _read_mapping(envelope_inventory, root / LOCKFILE_NAME) + require_supported_lockfile_version(metadata) + LockFile.from_yaml(raw.decode("utf-8")) + pack = metadata.get("pack") + if ( + not isinstance(pack, dict) + or pack.get("source") is not True + or pack.get("format") != BundleFormat.APM.value + or not isinstance(pack.get("bundle_files"), dict) + or not pack["bundle_files"] + ): + raise ValueError("Not an attested APM source package; repack with --format apm --source") + files = pack["bundle_files"] + if any(not isinstance(key, str) or not isinstance(value, str) for key, value in files.items()): + raise ValueError("Source-package bundle_files must map relative paths to SHA-256 strings") + actual = {asset.path for asset in envelope_assets} - {LOCKFILE_NAME} + if set(files) != actual: + raise ValueError("Source-package inventory is missing, unlisted, or ambiguous") + errors = verify_bundle_integrity(root, metadata) + if errors: + raise ValueError("Source-package integrity verification failed: " + "; ".join(errors)) + _, inventory, assets = _source_inventory(root / _PAYLOAD) + if files != {f"{_PAYLOAD}/{asset.path}": asset.sha256 for asset in assets}: + raise ValueError("Source-package inventory does not match declared resources and metadata") + return pack, inventory, assets + + +def restore_source_package(root: Path, output: Path, *, dry_run: bool) -> UnpackResult: + """Restore an attested payload into a new directory without executing anything.""" + from .unpacker import UnpackResult + + _require_new_destination(output) + pack, inventory, assets = _verify_source_package(root) + result = UnpackResult( + extracted_dir=output, + files=[asset.path for asset in assets], + verified=True, + pack_meta=pack, + ) + if dry_run: + return result + payload_hashes = { + asset.path: pack["bundle_files"][f"{_PAYLOAD}/{asset.path}"] for asset in assets + } + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".apm-restore-", dir=output.parent) as temporary: + staged = Path(temporary) / "payload" + staged.mkdir() + _copy_assets(inventory, assets, staged, expected_hashes=payload_hashes) + publish_directory_noreplace(staged, output) + return result diff --git a/src/apm_cli/bundle/unpacker.py b/src/apm_cli/bundle/unpacker.py index fa1711a259..b346ffd83d 100644 --- a/src/apm_cli/bundle/unpacker.py +++ b/src/apm_cli/bundle/unpacker.py @@ -40,6 +40,7 @@ def unpack_bundle( skip_verify: bool = False, dry_run: bool = False, force: bool = False, + source: bool = False, ) -> UnpackResult: """Extract and apply an APM bundle to a project directory. @@ -62,6 +63,10 @@ def unpack_bundle( ValueError: If verification finds files listed in the lockfile but absent from the bundle. """ + if source and (skip_verify or force): + raise ValueError("--source cannot bypass verification with --skip-verify or --force") + if source and bundle_path.is_symlink(): + raise ValueError("Source-package input must not be a symlink") # 1. If archive, extract to temp dir cleanup_temp = False if bundle_path.is_file() and bundle_path.name.endswith(".zip"): @@ -77,7 +82,11 @@ def unpack_bundle( max_entries=_MAX_ZIP_ENTRIES, max_uncompressed=_MAX_ZIP_UNCOMPRESSED, error_type=ValueError, + strict=source, ) + except zipfile.BadZipFile as exc: + shutil.rmtree(temp_dir, ignore_errors=True) + raise ValueError(f"Invalid ZIP bundle: {exc}") from exc except Exception: shutil.rmtree(temp_dir, ignore_errors=True) raise @@ -95,7 +104,7 @@ def unpack_bundle( temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-", dir=get_apm_temp_dir())) cleanup_temp = True try: - _extract_tar_gz_file(bundle_path, str(temp_dir)) + _extract_tar_gz_file(bundle_path, str(temp_dir), strict=source) except ArchiveError as exc: shutil.rmtree(temp_dir, ignore_errors=True) msg = str(exc) @@ -119,6 +128,11 @@ def unpack_bundle( raise FileNotFoundError(f"Bundle not found or unsupported format: {bundle_path}") try: + from .source_package import reject_source_deployment, restore_source_package + + if source: + return restore_source_package(source_dir, Path(output_dir), dry_run=dry_run) + reject_source_deployment(source_dir) # 2. Read apm.lock.yaml (or legacy apm.lock) from bundle lockfile_path = source_dir / LOCKFILE_NAME if not lockfile_path.exists(): diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 16f1e394ac..0d8820ac76 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -134,7 +134,8 @@ def resolve(self) -> click.Command: "unpack", "apm_cli.commands.pack", "unpack_cmd", - "[Deprecated] Extract an APM bundle into the current project. Use 'apm install ' instead -- this command will be removed in a future release.", + "Restore an experimental source package with --source into a new directory. " + "Without --source: deprecated deployment extraction; use 'apm install '.", ), ( "update", diff --git a/src/apm_cli/commands/pack.py b/src/apm_cli/commands/pack.py index 8ebf782910..0a7e916fc1 100644 --- a/src/apm_cli/commands/pack.py +++ b/src/apm_cli/commands/pack.py @@ -36,6 +36,10 @@ The lockfile (apm.lock.yaml) pins bundle contents. An enriched copy is embedded in each bundle. +Experimental --source with --format apm preserves declared resources +and exact author metadata instead. Restore with apm unpack --source; +source mode never deploys, executes, or builds marketplace artifacts. + Examples: \b @@ -183,6 +187,11 @@ def _parse_marketplace_filter( default=None, help="[Deprecated] Target platform filter. Bundles are now target-agnostic; the consumer's project decides where files land at install time. Value is recorded in pack.target as informational metadata only and is ignored by 'apm install'. The flag will be removed in a future release.", ) +@click.option( + "--source", + is_flag=True, + help="Experimental: pack declared resources and exact author metadata without deployment (requires --format apm).", +) @click.option( "--archive", is_flag=True, @@ -327,6 +336,7 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL check_versions, check_clean, strict_metadata, + source, ): """Pack APM artifacts: bundle and/or marketplace.json.""" effective_dry_run = dry_run or check_clean @@ -340,6 +350,27 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL ) except ValueError as exc: raise click.UsageError(str(exc)) from exc + if source: + if bundle_format is not BundleFormat.APM: + raise click.UsageError("--source requires --format apm") + if any( + ( + force, + target, + offline, + include_prerelease, + marketplace_filter, + marketplace_path_overrides, + legacy_skill_paths, + check_versions, + check_clean, + strict_metadata, + ) + ): + raise click.UsageError( + "--source cannot combine deployment or marketplace options; " + "use only --format apm, --archive, --archive-format, --output, --dry-run, --json, or --verbose" + ) # Error when --archive-format is explicitly set but --archive is not. if ( @@ -373,7 +404,9 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL # informational metadata only -- consumer-side install resolves the # deploy target from the consumer project's context, not from the # bundle. - if target is None: + if source: + effective_target = None + elif target is None: from ..core.target_detection import detect_target try: @@ -397,6 +430,7 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL bundle_archive_format=archive_format, bundle_output=Path(output), bundle_force=force, + bundle_source=source, marketplace_offline=offline, marketplace_include_prerelease=include_prerelease, marketplace_strict_metadata=strict_metadata, @@ -623,6 +657,12 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL ) elif sub.kind is OutputKind.PLUGIN_MANIFEST and isinstance(sub.payload, dict): envelope["plugin_manifests"] = sub.payload + elif sub.kind is OutputKind.BUNDLE and source and sub.payload is not None: + envelope["bundle"] = { + "source": True, + "path": str(sub.payload.bundle_path), + "files": sub.payload.files, + } if drift_metadata_enrichment is not None: envelope["metadata_enrichment"] = drift_metadata_enrichment.to_json_dict() metadata_warnings = set(drift_metadata_enrichment.warnings) @@ -653,11 +693,13 @@ def pack_cmd( # noqa: C901, PLR0912, PLR0913 -- Click handler, one param per CL target, effective_dry_run, show_zip_migration_notice=( - archive + not source + and archive and archive_format == "zip" and ctx.get_parameter_source("archive_format") is not click.core.ParameterSource.COMMANDLINE ), + source=source, ) elif sub.kind is OutputKind.MARKETPLACE: if ( @@ -734,6 +776,7 @@ def _render_bundle_result( dry_run, *, show_zip_migration_notice: bool = False, + source: bool = False, ): """Mirror the legacy ``apm pack`` output for the bundle producer.""" if pack_result is None: @@ -798,7 +841,13 @@ def _render_bundle_result( # project. Print a copy-pasteable share line so packing creates # the social hand-off naturally. if pack_result.bundle_path: - logger.info(f"Share with: apm install {pack_result.bundle_path}") + if source: + logger.info( + f"Restore without activation: apm unpack --source " + f"{shlex.quote(str(pack_result.bundle_path))} -o " + ) + else: + logger.info(f"Share with: apm install {pack_result.bundle_path}") def _render_marketplace_result(logger, report, dry_run, extra_warnings=None, outputs=None): @@ -877,11 +926,16 @@ def _render_marketplace_catalog(logger, written: list[tuple[str | None, Path]]) @click.command( name="unpack", help=( - "[Deprecated] Extract an APM bundle into the current project. " - "Use 'apm install ' instead -- this command will be removed in a future release." + "Restore an experimental source package with --source into a new directory. " + "Without --source: deprecated deployment extraction; use 'apm install '." ), ) @click.argument("bundle_path", type=click.Path(exists=True)) +@click.option( + "--source", + is_flag=True, + help="Restore exact source-package bytes to a new --output directory; never deploy or execute.", +) @click.option( "-o", "--output", @@ -901,13 +955,14 @@ def _render_marketplace_catalog(logger, written: list[tuple[str | None, Path]]) ) @click.option("--verbose", "-v", is_flag=True, help="Show detailed unpacking information") @click.pass_context -def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose): +def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose, source): """Extract an APM bundle into the project.""" logger = CommandLogger("unpack", verbose=verbose, dry_run=dry_run) - logger.warning( - "'apm unpack' is deprecated and will be removed in a future release. " - "Use 'apm install ' instead.", - ) + if not source: + logger.warning( + "'apm unpack' is deprecated and will be removed in a future release. " + "Use 'apm install ' instead.", + ) try: logger.start(f"Unpacking {bundle_path} -> {output}") @@ -917,10 +972,12 @@ def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose): skip_verify=skip_verify, dry_run=dry_run, force=force, + source=source, ) # Surface bundle metadata and warn on target mismatch - _log_bundle_meta(result, Path(output), logger) + if not source: + _log_bundle_meta(result, Path(output), logger) if result.canvas_blocked > 0: from apm_cli.core.experimental import is_enabled @@ -960,7 +1017,7 @@ def unpack_cmd(ctx, bundle_path, output, skip_verify, dry_run, force, verbose): verified_msg = " (verified)" if result.verified else "" logger.success(f"Unpacked {len(result.files)} file(s){verified_msg}") - except (FileNotFoundError, ValueError) as exc: + except (OSError, ValueError) as exc: logger.error(str(exc)) sys.exit(1) diff --git a/src/apm_cli/core/build_orchestrator.py b/src/apm_cli/core/build_orchestrator.py index a385eaa7bb..174cba23cb 100644 --- a/src/apm_cli/core/build_orchestrator.py +++ b/src/apm_cli/core/build_orchestrator.py @@ -47,6 +47,7 @@ class BuildOptions: bundle_archive_format: str = "zip" bundle_output: Path | None = None bundle_force: bool = False + bundle_source: bool = False # Marketplace-only options marketplace_offline: bool = False marketplace_include_prerelease: bool = False @@ -126,8 +127,9 @@ def produce(self, options: BuildOptions, logger: Any) -> ProducerResult: dry_run=options.dry_run, force=options.bundle_force, logger=logger, + source=options.bundle_source, ) - except (FileNotFoundError, ValueError) as exc: + except (OSError, ValueError) as exc: raise BuildError(str(exc)) from exc outputs: list[Path] = [] @@ -464,7 +466,7 @@ def detect_outputs(apm_yml_path: Path) -> set[OutputKind]: raise BuildError(f"{apm_yml_path} must be a YAML mapping at the top level.") data = loaded or {} - if data and data.get("dependencies") is not None: + if data and (data.get("dependencies") is not None or "resources" in data): out.add(OutputKind.BUNDLE) if data and data.get("marketplace"): out.add(OutputKind.MARKETPLACE) @@ -516,7 +518,9 @@ def __init__( ) def run(self, options: BuildOptions, logger: Any = None) -> BuildResult: - outputs_needed = detect_outputs(options.apm_yml_path) + outputs_needed = ( + {OutputKind.BUNDLE} if options.bundle_source else detect_outputs(options.apm_yml_path) + ) if not outputs_needed: raise BuildError( "apm.yml has neither 'dependencies:' nor 'marketplace:' " diff --git a/src/apm_cli/models/apm_package.py b/src/apm_cli/models/apm_package.py index b00e786c0e..11f26d2510 100644 --- a/src/apm_cli/models/apm_package.py +++ b/src/apm_cli/models/apm_package.py @@ -329,6 +329,7 @@ class APMPackage: None # Package content type: instructions, skill, hybrid, or prompts ) includes: str | list[str] | None = None # Include-only manifest: 'auto' or list of repo paths + resources: tuple[str, ...] = () # Top-level ``registries:`` block per docs/proposals/registry-api.md ยง3.1. # Maps registry name -> base URL. None when no ``registries:`` block is present. @@ -465,6 +466,17 @@ def from_mapping( from .manifest_contract import negotiate_manifest_contract manifest_contract = negotiate_manifest_contract(data) + resources: tuple[str, ...] = () + if "resources" in data: + from .manifest_contract import ManifestContract + from .package_resources import parse_resource_roots + + if manifest_contract is not ManifestContract.WORKING_DRAFT: + raise ValueError( + "'resources' is experimental working-draft functionality, " + "not part of the selected OpenAPM $schema contract" + ) + resources = parse_resource_roots(data["resources"]) # Required fields if "name" not in data: @@ -614,6 +626,7 @@ def from_mapping( canonical_targets=canonical_targets, type=pkg_type, includes=includes, + resources=resources, registries=registries, default_registry=default_registry, manifest_contract=manifest_contract.value, diff --git a/src/apm_cli/models/format_detection.py b/src/apm_cli/models/format_detection.py index cbc926932e..9ba852e5ea 100644 --- a/src/apm_cli/models/format_detection.py +++ b/src/apm_cli/models/format_detection.py @@ -372,6 +372,9 @@ def detect( agent_plugin_detection: AgentPluginDetection | None = None, ) -> DetectionReport: """Run all detectors against ``package_path`` and return the report.""" + from ..bundle.source_package import reject_source_deployment + + reject_source_deployment(package_path) apm_yml_ev: ApmYmlFormatEvidence | None = None skill_md_ev: SkillMdFormatEvidence | None = None hook_json_ev: HookJsonFormatEvidence | None = None diff --git a/src/apm_cli/models/package_resources.py b/src/apm_cli/models/package_resources.py new file mode 100644 index 0000000000..a9519ec8d8 --- /dev/null +++ b/src/apm_cli/models/package_resources.py @@ -0,0 +1,93 @@ +"""Selection authority for non-activating, working-draft package resources.""" + +from __future__ import annotations + +from itertools import pairwise +from pathlib import Path + +from ..agent_plugins.assets import AssetInventory, normalized_path_key +from ..agent_plugins.constants import MAX_COMPONENT_ASSET_ENTRIES +from ..agent_plugins.ir import AgentPluginAsset +from ..utils.path_security import ( + ensure_path_within, + has_symlink_component, + validate_portable_relative_path, +) + +_RESERVED = frozenset( + { + "apm.yml", + "apm.lock", + "apm.lock.yaml", + "plugin.json", + "mcp.json", + "apm_modules", + "node_modules", + "__pycache__", + "build", + "dist", + } +) + + +def _validate_resource_path(path: str) -> None: + validate_portable_relative_path(path, context="resources path") + if any( + part.startswith(".") or normalized_path_key(part) in _RESERVED for part in path.split("/") + ): + raise ValueError( + f"Unsafe resources path {path!r}: hidden paths, package metadata, and " + "build/cache directories are not resources. Select dedicated content directories." + ) + + +def parse_resource_roots(value: object) -> tuple[str, ...]: + """Parse explicit, nonoverlapping directory roots; absence is handled by the caller.""" + if not isinstance(value, list) or not value: + raise ValueError("'resources' must be a non-empty list of package-relative directories") + if len(value) > MAX_COMPONENT_ASSET_ENTRIES: + raise ValueError("'resources' exceeds the package entry budget") + roots: list[str] = [] + for item in value: + if not isinstance(item, str): + raise ValueError("'resources' entries must be package-relative directory strings") + _validate_resource_path(item) + roots.append(item) + ordered = sorted((tuple(normalized_path_key(root).split("/")), root) for root in roots) + for (previous, left), (current, right) in pairwise(ordered): + if current[: len(previous)] == previous: + raise ValueError(f"Overlapping or case-aliased resources roots: {left}, {right}") + return tuple(roots) + + +def collect_package_resources( + root: Path, roots: tuple[str, ...], inventory: AssetInventory +) -> tuple[AgentPluginAsset, ...]: + """Inventory declared directories without interpreting any file as a primitive.""" + assets: list[AgentPluginAsset] = [] + for relative in roots: + directory = root / relative + ensure_path_within(directory, root) + if has_symlink_component(root, directory, raise_on_error=True) or not directory.is_dir(): + raise ValueError(f"Resource directory {relative!r} is missing or symlinked") + # Inventorying parents also catches a declared root's case aliases. + parent = root + for part in relative.split("/"): + siblings = inventory.list_component_candidates(parent) + matches = [ + entry.name + for entry in siblings + if normalized_path_key(entry.name) == normalized_path_key(part) + ] + if matches != [part]: + raise ValueError(f"Resource directory {relative!r} has an ambiguous path") + parent /= part + selected = inventory.collect_component(directory) + if not selected: + raise ValueError( + f"Resource directory {relative!r} is empty; select content directories" + ) + for asset in selected: + _validate_resource_path(asset.path) + assets.extend(selected) + return tuple(assets) diff --git a/src/apm_cli/utils/archive.py b/src/apm_cli/utils/archive.py index 7582f78500..8dce370348 100644 --- a/src/apm_cli/utils/archive.py +++ b/src/apm_cli/utils/archive.py @@ -11,6 +11,7 @@ import io import os import shutil +import stat import tarfile import uuid import zipfile @@ -24,7 +25,9 @@ from apm_cli.utils.path_security import ( PathTraversalError, ensure_path_within, + source_permission_bits, validate_path_segments, + validate_portable_relative_path, ) MAX_ZIP_ENTRIES = 10_000 @@ -45,6 +48,37 @@ class ArchiveError(Exception): """Raised when an archive cannot be downloaded or extracted safely.""" +class _StrictArchiveInventory: + """Reject duplicate, case-aliased, and file/directory-conflicting source members.""" + + def __init__(self) -> None: + self._paths: dict[str, tuple[str, bool]] = {} + self._declared: set[str] = set() + + def admit(self, name: str, *, directory: bool) -> None: + from ..agent_plugins.assets import normalized_path_key + + path = name[:-1] if directory and name.endswith("/") else name + validate_portable_relative_path(path, context="source archive member") + key = normalized_path_key(path) + if key in self._declared: + raise ValueError(f"Duplicate or case-aliased source archive member: {name}") + self._declared.add(key) + if len(self._declared) > MAX_ZIP_ENTRIES: + raise ValueError(f"Source archive exceeds {MAX_ZIP_ENTRIES} entries") + parts = path.split("/") + for index in range(1, len(parts) + 1): + prefix = "/".join(parts[:index]) + kind = directory if index == len(parts) else True + folded = normalized_path_key(prefix) + previous = self._paths.get(folded) + if previous is not None and previous != (prefix, kind): + raise ValueError(f"Ambiguous source archive path: {name}") + self._paths[folded] = (prefix, kind) + if len(self._paths) > MAX_ZIP_ENTRIES: + raise ValueError(f"Source archive exceeds {MAX_ZIP_ENTRIES} path entries") + + def validate_archive_format(archive_format: str) -> None: """Raise ValueError unless *archive_format* is supported.""" if archive_format not in SUPPORTED_ARCHIVE_FORMATS: @@ -193,6 +227,7 @@ def safe_extract_zip( max_uncompressed: int = MAX_ZIP_UNCOMPRESSED, error_type: type[_ErrorT] = ValueError, member_name_transform: Callable[[str], str | None] | None = None, + strict: bool = False, ) -> list[str]: """Safely stream-extract *zf* under *dest_root* with zip-bomb limits. @@ -208,6 +243,7 @@ def safe_extract_zip( extracted: list[str] = [] total_uncompressed = 0 + inventory = _StrictArchiveInventory() if strict else None for info in members: unix_mode = (info.external_attr >> 16) & 0xFFFF if unix_mode and (unix_mode & 0xF000) == 0xA000: @@ -217,6 +253,15 @@ def safe_extract_zip( member_name = member_name_transform(member_name) if member_name is None: continue + if inventory is not None: + try: + inventory.admit(member_name, directory=info.is_dir()) + mode_type = stat.S_IFMT(unix_mode) + if mode_type not in (0, stat.S_IFDIR if info.is_dir() else stat.S_IFREG): + raise ValueError(f"Nonregular source archive member: {member_name}") + source_permission_bits(unix_mode) + except ValueError as exc: + _raise(error_type, str(exc)) target = _zip_member_target(member_name, dest_root, error_type=error_type) if target is None: continue @@ -240,16 +285,27 @@ def safe_extract_zip( fh.write(chunk) total_uncompressed = next_total if unix_mode: - os.chmod(target, unix_mode & 0o755) + os.chmod(target, unix_mode & (0o777 if strict else 0o755)) extracted.append(member_name) return extracted -def _extract_tar_archive(archive: tarfile.TarFile, dest_dir: str) -> list[str]: +def _extract_tar_archive( + archive: tarfile.TarFile, dest_dir: str, *, strict: bool = False +) -> list[str]: """Extract an opened tar archive into *dest_dir* with safety checks.""" extracted: list[str] = [] total_size = 0 - for member in archive.getmembers(): + inventory = _StrictArchiveInventory() if strict else None + for member in archive: + if inventory is not None: + try: + inventory.admit(member.name, directory=member.isdir()) + if not (member.isdir() or member.isreg()): + raise ValueError(f"Nonregular source archive member: {member.name}") + source_permission_bits(member.mode) + except ValueError as exc: + raise ArchiveError(str(exc)) from exc if member.isdir(): continue if member.issym() or member.islnk(): @@ -264,6 +320,8 @@ def _extract_tar_archive(archive: tarfile.TarFile, dest_dir: str) -> list[str]: continue with src, open(destination, "wb") as dst: total_size = _copy_member_within_limit(src, dst, total_size) + if strict: + os.chmod(destination, member.mode & 0o777) extracted.append(member.name) return extracted @@ -277,11 +335,11 @@ def _extract_tar_gz(data: bytes, dest_dir: str) -> list[str]: raise ArchiveError(f"Failed to read tar.gz archive: {exc}") from exc -def _extract_tar_gz_file(path: Path, dest_dir: str) -> list[str]: +def _extract_tar_gz_file(path: Path, dest_dir: str, *, strict: bool = False) -> list[str]: """Extract a tar.gz archive file into *dest_dir* with safety checks.""" try: with tarfile.open(path, mode="r:gz") as archive: - return _extract_tar_archive(archive, dest_dir) + return _extract_tar_archive(archive, dest_dir, strict=strict) except tarfile.TarError as exc: raise ArchiveError(f"Failed to read tar.gz archive: {exc}") from exc diff --git a/src/apm_cli/utils/atomic_io.py b/src/apm_cli/utils/atomic_io.py index 943dc8c3aa..6de6aeaa1f 100644 --- a/src/apm_cli/utils/atomic_io.py +++ b/src/apm_cli/utils/atomic_io.py @@ -1,4 +1,4 @@ -"""Atomic file-write primitive for APM. +"""Atomic file writes and exclusive directory publication for APM. Writes go to a temp file in the same directory as the target, then are renamed via :func:`os.replace`. A crash mid-write cannot leave a half- @@ -9,15 +9,63 @@ ``apm_cli.commands._helpers._atomic_write`` (kept as an alias for backward compatibility with existing tests) and ``apm_cli.compilation.output_writer`` route through here. +Source packages use ``publish_directory_noreplace`` to publish staged +directories without replacing concurrently created consumer output. """ import contextlib +import errno import os import stat +import sys import tempfile from pathlib import Path +def publish_directory_noreplace(source: Path, destination: Path) -> None: + """Atomically move a staged directory without replacing any destination. + + Both paths must be on the same filesystem. Unsupported platforms, + libraries and filesystems fail closed; an exists-check plus ordinary + POSIX rename cannot provide this guarantee. + """ + if sys.platform == "win32": + # Windows rename refuses existing destinations, including empty dirs. + os.rename(source, destination) + return + if sys.platform not in {"linux", "darwin"}: + raise OSError(errno.ENOTSUP, "Atomic no-replace directory publication is unavailable") + + import ctypes + + source_bytes, destination_bytes = os.fsencode(source), os.fsencode(destination) + if b"\0" in source_bytes or b"\0" in destination_bytes: + raise ValueError("Directory publication paths cannot contain NUL bytes") + libc = ctypes.CDLL(None, use_errno=True) + arguments: tuple[bytes | int, ...] + if sys.platform == "darwin": + rename = getattr(libc, "renamex_np", None) + argument_types = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + arguments = (source_bytes, destination_bytes, 0x00000004) # RENAME_EXCL + else: + rename = getattr(libc, "renameat2", None) + argument_types = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + arguments = (-100, source_bytes, -100, destination_bytes, 1) # AT_FDCWD, RENAME_NOREPLACE + if rename is None: + raise OSError(errno.ENOTSUP, "Atomic no-replace directory publication is unavailable") + rename.argtypes = argument_types + rename.restype = ctypes.c_int + if rename(*arguments) != 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), os.fspath(source), None, os.fspath(destination)) + + def _replace_atomic_file(source: str, destination: Path) -> None: """Replace one atomic temp file, with a narrow installed-binary test seam.""" if os.environ.get("APM_TEST_FAIL_LOCK_REPLACE") == "1" and destination.name == "apm.lock.yaml": diff --git a/src/apm_cli/utils/path_security.py b/src/apm_cli/utils/path_security.py index 247f8dd532..1d58638b87 100644 --- a/src/apm_cli/utils/path_security.py +++ b/src/apm_cli/utils/path_security.py @@ -30,6 +30,27 @@ class PathTraversalError(ValueError): """Raised when a computed path escapes its expected base directory.""" +def source_permission_bits(mode: int) -> int: + """Admit ordinary source-file permissions, never set-ID or sticky bits.""" + if mode & 0o7000: + raise ValueError("Source-package files cannot carry special permission bits") + return mode & 0o777 + + +def validate_portable_relative_path(value: str, *, context: str = "path") -> None: + """Require an unambiguous relative POSIX path, including on Windows.""" + if len(value.encode("utf-8")) > 4096 or len(value.split("/")) > 128: + raise PathTraversalError(f"Invalid {context}: path exceeds the length or depth limit") + validate_path_segments(value, context=context, reject_empty=True) + reserved = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} + reserved.update(f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(10)) + if not value or any(ord(char) < 32 or char in '\\<>:"|?*' for char in value): + raise PathTraversalError(f"Invalid {context}: use a portable relative POSIX path") + for part in value.split("/"): + if part.endswith((".", " ")) or part.split(".")[0].upper() in reserved: + raise PathTraversalError(f"Invalid {context}: non-portable path segment {part!r}") + + def decode_url_path_segments( raw_path: str, *, diff --git a/src/apm_cli/utils/yaml_io.py b/src/apm_cli/utils/yaml_io.py index a6fe44e28a..b63a49169d 100644 --- a/src/apm_cli/utils/yaml_io.py +++ b/src/apm_cli/utils/yaml_io.py @@ -299,7 +299,21 @@ def flatten_mapping(self, node: Any) -> Any: self._flatten_depth -= 1 -def _bounded_load(stream: Any) -> Any: +class _UniqueKeySafeLoader(_BoundedSafeLoader): + """Bounded loader for integrity documents that cannot tolerate duplicate keys.""" + + def construct_mapping(self, node: Any, deep: bool = False) -> dict: + self.flatten_mapping(node) + keys = [self.construct_object(key, deep=deep) for key, _ in node.value] + try: + if len(set(keys)) != len(keys): + raise yaml.YAMLError("Duplicate YAML mapping key") + except TypeError as exc: + raise yaml.YAMLError("YAML mapping keys must be scalar values") from exc + return super().construct_mapping(node, deep=deep) + + +def _bounded_load(stream: Any, *, reject_duplicate_keys: bool = False) -> Any: """Parse *stream* with the merge-bounded SafeLoader, failing closed. Centralizes the round-16 bounded-loader entrypoint AND normalizes the @@ -316,7 +330,8 @@ def _bounded_load(stream: Any) -> Any: ``.prompt.md`` -> whole-run DoS) instead of the intended per-file skip. """ try: - return yaml.load(stream, Loader=_BoundedSafeLoader) # noqa: S506 - SafeLoader subclass + loader = _UniqueKeySafeLoader if reject_duplicate_keys else _BoundedSafeLoader + return yaml.load(stream, Loader=loader) # noqa: S506 - SafeLoader subclass except yaml.YAMLError: raise except (ValueError, RecursionError) as exc: @@ -338,7 +353,7 @@ def load_yaml(path: str | Path) -> dict[str, Any] | None: return _bounded_load(fh) -def load_yaml_str(text: str) -> dict[str, Any] | None: +def load_yaml_str(text: str, *, reject_duplicate_keys: bool = False) -> dict[str, Any] | None: """Load YAML from an in-memory string with the bounded SafeLoader. The string-input twin of :func:`load_yaml`, for callers that already hold @@ -353,7 +368,7 @@ def load_yaml_str(text: str) -> dict[str, Any] | None: Returns parsed data or ``None`` for empty input. Raises ``yaml.YAMLError`` on malformed, over-budget, huge-int, or deeply-nested input. """ - return _bounded_load(text) + return _bounded_load(text, reject_duplicate_keys=reject_duplicate_keys) def _roundtrip_yaml() -> Any: diff --git a/tests/integration/test_architecture_source_resources.py b/tests/integration/test_architecture_source_resources.py new file mode 100644 index 0000000000..f312ea3662 --- /dev/null +++ b/tests/integration/test_architecture_source_resources.py @@ -0,0 +1,65 @@ +"""Mutation proof for the source-package selection and non-activation boundary.""" + +from pathlib import Path + +import pytest + +from scripts.architecture_linter.runner import run_selected_rules + +pytestmark = pytest.mark.component +ROOT = Path(__file__).resolve().parents[2] +RULE = "marketplace-integrations-source-resources" + + +@pytest.mark.parametrize( + ("path", "old", "new"), + [ + ("bundle/packer.py", "require_resource_pack_mode(package)", "pass"), + ("bundle/plugin_exporter.py", "require_resource_pack_mode(package)", "pass"), + ("bundle/agent_plugin_exporter.py", "require_resource_pack_mode(package)", "pass"), + ("bundle/unpacker.py", "reject_source_deployment(source_dir)", "pass"), + ("models/format_detection.py", "reject_source_deployment(package_path)", "pass"), + ("bundle/source_package.py", "verify_bundle_integrity(root, metadata)", "[]"), + ( + "bundle/source_package.py", + '{"pack": {"bundle_files": expected_hashes}}', + '{"pack": {"bundle_files": {}}}', + ), + ( + "bundle/source_package.py", + "(LOCKFILE_NAME, LEGACY_LOCKFILE_NAME)", + "(LOCKFILE_NAME,)", + ), + ( + "bundle/source_package.py", + "publish_directory_noreplace(staged, destination)", + "staged.rename(destination)", + ), + ( + "bundle/source_package.py", + "publish_directory_noreplace(staged, output)", + "staged.rename(output)", + ), + ( + "models/package_resources.py", + "from pathlib import Path", + "from pathlib import Path\nimport subprocess", + ), + ], +) +def test_source_owner_guard_rejects_boundary_mutations(path: str, old: str, new: str) -> None: + relative = f"src/apm_cli/{path}" + source = (ROOT / relative).read_text(encoding="utf-8") + assert old in source + result = run_selected_rules( + ROOT, (RULE,), source_overrides={relative: source.replace(old, new)} + ) + assert result.failures == () + assert result.exit_code == 2 + assert any(item.rule_id == RULE for item in result.violations) + + +def test_source_owner_guard_accepts_current_tree() -> None: + result = run_selected_rules(ROOT, (RULE,)) + assert result.violations == () + assert result.failures == () diff --git a/tests/integration/test_source_package_cli.py b/tests/integration/test_source_package_cli.py new file mode 100644 index 0000000000..08223bf680 --- /dev/null +++ b/tests/integration/test_source_package_cli.py @@ -0,0 +1,113 @@ +"""Real installed Python CLI roundtrips; no released/native binary claims.""" + +import hashlib +import os +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest + +from apm_cli.bundle.packer import pack_bundle +from tests.utils.source_package import make_source_package + +pytestmark = pytest.mark.e2e + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +def test_source_archive_survives_author_removal( + tmp_path: Path, apm_engine_command: tuple[str, ...], archive_format: str +) -> None: + author = tmp_path / "author" + expected = make_source_package(author) + consumer = tmp_path / "consumer" + consumer.mkdir() + (consumer / "seed.txt").write_bytes(b"consumer-owned") + home = tmp_path / "home" + home.mkdir() + environment = os.environ | { + "HOME": str(home), + "USERPROFILE": str(home), + "APM_DISABLE_UPDATE_CHECK": "1", + } + + def run(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [*apm_engine_command, *args], + cwd=cwd, + env=environment, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + packed = run( + author, + "pack", + "--format", + "apm", + "--source", + "--archive", + "--archive-format", + archive_format, + "-o", + "../artifacts", + ) + assert packed.returncode == 0, packed.stdout + packed.stderr + archive = tmp_path / f"artifacts/software-factory-1.0.0.{archive_format}" + assert archive.is_file() + shutil.rmtree(author) + restored = run(consumer, "unpack", "--source", str(archive), "-o", "acquired") + assert restored.returncode == 0, restored.stdout + restored.stderr + actual = { + path.relative_to(consumer / "acquired").as_posix(): path.read_bytes() + for path in (consumer / "acquired").rglob("*") + if path.is_file() + } + assert actual == expected + assert {path: hashlib.sha256(data).hexdigest() for path, data in actual.items()} == { + path: hashlib.sha256(data).hexdigest() for path, data in expected.items() + } + assert (consumer / "seed.txt").read_bytes() == b"consumer-owned" + assert sorted(path.name for path in consumer.iterdir()) == ["acquired", "seed.txt"] + rejected = run(consumer, "install", str(archive)) + assert rejected.returncode != 0 + assert "apm unpack --source" in rejected.stdout + rejected.stderr + assert sorted(path.name for path in consumer.iterdir()) == ["acquired", "seed.txt"] + + +@pytest.mark.parametrize("damage", ["corrupt", "missing", "unsafe"]) +def test_real_cli_rejects_damaged_source_archives( + tmp_path: Path, apm_engine_command: tuple[str, ...], damage: str +) -> None: + author = tmp_path / "author" + make_source_package(author) + bundle = pack_bundle(author, tmp_path / "artifacts", fmt="apm", source=True, archive=True) + damaged = tmp_path / "damaged.zip" + resource = "software-factory-1.0.0/package/contracts/phase-0.contract.md" + with zipfile.ZipFile(bundle.bundle_path) as original, zipfile.ZipFile(damaged, "w") as target: + for item in original.infolist(): + payload = original.read(item) + if item.filename == resource: + if damage == "missing": + continue + if damage == "corrupt": + payload = b"changed after publication" + target.writestr(item, payload) + if damage == "unsafe": + target.writestr("../escape", b"must never escape") + shutil.rmtree(author) + output = tmp_path / "restore" + result = subprocess.run( + [*apm_engine_command, "unpack", "--source", str(damaged), "-o", str(output)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert result.returncode == 1, result.stdout + result.stderr + assert not output.exists() + assert not (tmp_path / "escape").exists() diff --git a/tests/unit/bundle/test_source_package.py b/tests/unit/bundle/test_source_package.py new file mode 100644 index 0000000000..f496b7a5a6 --- /dev/null +++ b/tests/unit/bundle/test_source_package.py @@ -0,0 +1,558 @@ +"""Hermetic admission and failure controls for non-activating source packages.""" + +import io +import json +import os +import stat +import tarfile +import zipfile +from contextlib import contextmanager +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.bundle.local_bundle import detect_local_bundle +from apm_cli.bundle.packer import pack_bundle +from apm_cli.bundle.unpacker import unpack_bundle +from apm_cli.commands.pack import pack_cmd, unpack_cmd +from apm_cli.deps.lockfile import LockedDependency, LockFile +from apm_cli.models.apm_package import APMPackage +from apm_cli.models.manifest_contract import OPENAPM_V01_SCHEMA_URI +from apm_cli.utils.yaml_io import dump_yaml, load_yaml, load_yaml_str +from tests.utils.source_package import make_source_package + +pytestmark = pytest.mark.component + + +@pytest.fixture +def author(tmp_path: Path) -> Path: + root = tmp_path / "author" + make_source_package(root) + return root + + +def _pack(author: Path, **kwargs): + return pack_bundle(author, author.parent / "dist", fmt="apm", source=True, **kwargs) + + +@pytest.mark.parametrize("representation", ["directory", "zip", "tar.gz"]) +@pytest.mark.windows_compat +def test_exact_roundtrip_without_author(author: Path, representation: str) -> None: + expected = { + path.relative_to(author).as_posix(): path.read_bytes() + for path in author.rglob("*") + if path.is_file() + } + result = _pack( + author, + archive=representation != "directory", + archive_format="zip" if representation == "directory" else representation, + ) + author.rename(author.with_name("unavailable-author")) + restored = author.parent / "restored" + unpacked = unpack_bundle(result.bundle_path, restored, source=True) + actual = { + path.relative_to(restored).as_posix(): path.read_bytes() + for path in restored.rglob("*") + if path.is_file() + } + assert actual == expected + assert unpacked.verified and len(unpacked.files) == 17 + assert not (restored / "EXECUTED").exists() + + +@pytest.mark.parametrize( + "roots", + [ + None, + [], + "auto", + ["."], + ["/contracts"], + ["../contracts"], + ["contracts/../checks"], + ["contracts/"], + ["contracts//nested"], + ["C:contracts"], + [r"contracts\nested"], + ["contracts", "contracts/nested"], + ["contracts", "CONTRACTS"], + [".git"], + ["apm_modules"], + ["factory/apm.yml"], + ["contracts/CON"], + ["contracts/secret:stream"], + ["contracts/%252e%252e"], + [1], + ["a/" * 129 + "b"], + ["a" * 4097], + ], +) +def test_reject_invalid_resource_declarations(author: Path, roots: object) -> None: + with pytest.raises(ValueError): + APMPackage.from_mapping( + {"name": "factory", "version": "1", "resources": roots}, package_path=author + ) + + +def test_resources_are_not_a_normative_contract(author: Path) -> None: + with pytest.raises(ValueError, match="working-draft"): + APMPackage.from_mapping( + { + "name": "factory", + "version": "1", + "$schema": OPENAPM_V01_SCHEMA_URI, + "resources": ["contracts"], + }, + package_path=author, + ) + + +@pytest.mark.parametrize("fmt", [None, "agent-plugin", "claude-plugin", "apm"]) +def test_deployment_formats_refuse_resource_loss(author: Path, fmt: str | None) -> None: + with pytest.raises(ValueError, match="non-activating"): + pack_bundle(author, author.parent / "dist", fmt=fmt) + assert not (author.parent / "dist").exists() + + +@pytest.mark.parametrize( + "problem", ["missing", "empty", "file", "metadata", "hidden", "symlink", "fifo"] +) +def test_pack_fails_closed_on_invalid_content(author: Path, problem: str) -> None: + directory = author / "contracts" + if problem in {"missing", "empty", "file"}: + directory.rename(author / "unselected") + if problem == "empty": + directory.mkdir() + elif problem == "file": + directory.write_text("not a directory") + elif problem == "metadata": + (directory / "apm.yml").write_text("secret: do-not-pack\n") + elif problem == "hidden": + (directory / ".env").write_text("secret=do-not-pack\n") + elif problem == "symlink": + try: + (directory / "link").symlink_to(author / "checks", target_is_directory=True) + except OSError: + pytest.skip("Platform does not permit symlink creation") + else: + if not hasattr(os, "mkfifo"): + pytest.skip("FIFO creation is unavailable") + os.mkfifo(directory / "pipe") + with pytest.raises(ValueError): + _pack(author) + assert not (author.parent / "dist").exists() + + +@pytest.mark.parametrize("kind", ["bytes", "entries"]) +def test_inventory_work_is_bounded(author: Path, monkeypatch, kind: str) -> None: + from apm_cli.agent_plugins import assets + + monkeypatch.setattr( + assets, "MAX_COMPONENT_ASSET_BYTES" if kind == "bytes" else "MAX_COMPONENT_ASSET_ENTRIES", 1 + ) + with pytest.raises(ValueError, match=r"budget|limit"): + _pack(author) + assert not (author.parent / "dist").exists() + + +@pytest.mark.parametrize( + "damage", ["corrupt", "missing", "extra", "inventory", "marker", "version", "duplicate-key"] +) +def test_restore_refuses_tampering_before_output(author: Path, damage: str) -> None: + bundle = _pack(author).bundle_path + metadata = load_yaml(bundle / "apm.lock.yaml") + path = bundle / "package/contracts/phase-0.contract.md" + if damage == "corrupt": + path.write_bytes(b"changed") + elif damage == "missing": + path.unlink() + elif damage == "extra": + (bundle / "plugin.json").write_text("{}") + elif damage == "inventory": + metadata["pack"]["bundle_files"] = {} + elif damage == "marker": + metadata["pack"]["source"] = "true" + elif damage == "version": + metadata["lockfile_version"] = "999" + elif damage == "duplicate-key": + with (bundle / "apm.lock.yaml").open("a") as handle: + handle.write("pack: {}\n") + if damage in {"inventory", "marker", "version"}: + dump_yaml(metadata, bundle / "apm.lock.yaml") + output = author.parent / "restored" + with pytest.raises(ValueError): + unpack_bundle(bundle, output, source=True) + assert not output.exists() + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +@pytest.mark.parametrize( + "member", + ["../escape", "package/a", "PACKAGE/a", "package/a:stream", "package/CON", "package/a/child"], +) +def test_strict_archives_reject_ambiguous_members( + tmp_path: Path, archive_format: str, member: str +) -> None: + archive = tmp_path / f"hostile.{archive_format}" + entries = [("package/a", b"first"), (member, b"second")] + if archive_format == "zip": + with zipfile.ZipFile(archive, "w") as handle: + for name, payload in entries: + handle.writestr(name, payload) + else: + with tarfile.open(archive, "w:gz") as handle: + for name, payload in entries: + info = tarfile.TarInfo(name) + info.size = len(payload) + handle.addfile(info, io.BytesIO(payload)) + with pytest.raises(ValueError): + unpack_bundle(archive, tmp_path / "restored", source=True) + assert not (tmp_path / "restored").exists() + assert not (tmp_path / "escape").exists() + + +@pytest.mark.parametrize("archive", [False, True]) +def test_marked_source_cannot_enter_deployment(author: Path, archive: bool) -> None: + bundle = _pack(author, archive=archive).bundle_path + with pytest.raises(ValueError, match="cannot be deployed"): + unpack_bundle(bundle, author.parent / "consumer") + with pytest.raises(ValueError, match="cannot be deployed"): + detect_local_bundle(bundle) + assert not (author.parent / "consumer").exists() + + +def test_source_marker_blocks_declarative_plugin_admission(author: Path) -> None: + from apm_cli.models.validation import detect_package_type + + bundle = _pack(author).bundle_path + (bundle / "plugin.json").write_text('{"name": "injected-plugin"}') + with pytest.raises(ValueError, match="cannot be deployed"): + detect_package_type(bundle) + + +@pytest.mark.parametrize("lock_name", ["apm.lock.yaml", "apm.lock"]) +def test_source_probe_preserves_legacy_metadata_semantics(tmp_path: Path, lock_name: str) -> None: + bundle = tmp_path / "legacy" + bundle.mkdir() + (bundle / "plugin.json").write_text('{"name": "legacy"}') + (bundle / lock_name).write_text( + "lockfile_version: '1'\n" + "defaults: &base {format: apm}\n" + "pack:\n <<: *base\n format: claude-plugin\n" + + "# padding for an ordinary legacy lock " + + "x" * (4 * 1024 * 1024) + + "\n" + ) + detected = detect_local_bundle(bundle) + assert detected is not None + assert detected.package_id == "legacy" + preview = unpack_bundle(bundle, tmp_path / "unused", dry_run=True) + assert preview.pack_meta["format"] == "claude-plugin" + assert preview.verified + assert not (tmp_path / "unused").exists() + + +@pytest.mark.parametrize("lock_name", ["apm.lock.yaml", "apm.lock"]) +@pytest.mark.parametrize("other_lock", [False, True]) +@pytest.mark.parametrize("admission", ["unpack", "local", "plugin"]) +def test_all_source_marker_names_block_deployment( + tmp_path: Path, lock_name: str, other_lock: bool, admission: str +) -> None: + from apm_cli.models.validation import detect_package_type + + bundle = tmp_path / "marked" + bundle.mkdir() + deployed = ".github/instructions/source-only.instructions.md" + (bundle / deployed).parent.mkdir(parents=True) + (bundle / deployed).write_text("must remain inert\n") + lock = LockFile() + lock.add_dependency( + LockedDependency( + repo_url="example/package", resolved_commit="abc123", deployed_files=[deployed] + ) + ) + metadata = load_yaml_str(lock.to_yaml()) + metadata["pack"] = {"format": "apm", "source": True} + dump_yaml(metadata, bundle / lock_name) + if other_lock: + other_name = "apm.lock" if lock_name == "apm.lock.yaml" else "apm.lock.yaml" + dump_yaml({"lockfile_version": "1"}, bundle / other_name) + (bundle / "plugin.json").write_text('{"name": "marked-plugin"}') + output = tmp_path / "consumer" + with pytest.raises(ValueError, match="cannot be deployed"): + if admission == "unpack": + unpack_bundle(bundle, output) + elif admission == "local": + detect_local_bundle(bundle) + else: + detect_package_type(bundle) + assert not output.exists() + + +@pytest.mark.parametrize("metadata", ["apm.yml", "apm.lock.yaml"]) +def test_exact_metadata_names_and_presence_are_required(author: Path, metadata: str) -> None: + (author / metadata).rename(author / metadata.upper()) + with pytest.raises(ValueError, match="exact"): + _pack(author) + + +def test_unsupported_author_lock_version(author: Path) -> None: + (author / "apm.lock.yaml").write_text("lockfile_version: '999'\n") + with pytest.raises(ValueError, match="Unsupported lockfile version"): + _pack(author) + assert not (author.parent / "dist").exists() + + +@pytest.mark.parametrize("entry_type", [tarfile.FIFOTYPE, tarfile.SYMTYPE, tarfile.LNKTYPE]) +def test_source_tar_rejects_special_members(tmp_path: Path, entry_type: bytes) -> None: + archive = tmp_path / "special.tar.gz" + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("special") + info.type = entry_type + info.linkname = "other" + handle.addfile(info) + with pytest.raises(ValueError, match="Nonregular"): + unpack_bundle(archive, tmp_path / "restored", source=True) + assert not (tmp_path / "restored").exists() + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +def test_source_archives_reject_special_permission_bits( + tmp_path: Path, archive_format: str +) -> None: + archive = tmp_path / f"permissions.{archive_format}" + if archive_format == "zip": + with zipfile.ZipFile(archive, "w") as handle: + info = zipfile.ZipInfo("unsafe") + info.external_attr = (stat.S_IFREG | 0o4755) << 16 + handle.writestr(info, b"not executable") + else: + with tarfile.open(archive, "w:gz") as handle: + info = tarfile.TarInfo("unsafe") + info.mode = 0o4755 + handle.addfile(info) + with pytest.raises(ValueError, match="special permission bits"): + unpack_bundle(archive, tmp_path / "restored", source=True) + assert not (tmp_path / "restored").exists() + + +@pytest.mark.parametrize("option", ["--force", "--skip-verify"]) +def test_source_cli_rejects_verification_bypass(author: Path, option: str) -> None: + bundle = _pack(author).bundle_path + output = author.parent / "restored" + result = CliRunner().invoke(unpack_cmd, ["--source", str(bundle), "-o", str(output), option]) + assert result.exit_code == 1 + assert "cannot bypass verification" in result.output + assert not output.exists() + + +def test_no_execution_and_no_other_build_producers(author: Path, monkeypatch) -> None: + def forbidden(*args, **kwargs): + pytest.fail("Source operation crossed an execution, network, or plugin boundary") + + monkeypatch.setattr("subprocess.run", forbidden) + monkeypatch.setattr("subprocess.Popen", forbidden) + monkeypatch.setattr("requests.sessions.Session.request", forbidden) + monkeypatch.setattr("apm_cli.core.build_orchestrator.PluginManifestProducer.produce", forbidden) + monkeypatch.setattr("apm_cli.core.build_orchestrator.MarketplaceProducer.produce", forbidden) + monkeypatch.chdir(author) + runner = CliRunner() + packed = runner.invoke(pack_cmd, ["--format", "apm", "--source"]) + assert packed.exit_code == 0, packed.output + assert "apm unpack --source" in packed.output + assert "apm install" not in packed.output + restored = runner.invoke( + unpack_cmd, ["--source", "build/software-factory-1.0.0", "-o", "../restored"] + ) + assert restored.exit_code == 0, restored.output + assert not (author / ".github").exists() + assert not (author / "EXECUTED").exists() + + +def test_existing_output_and_bypasses_are_rejected(author: Path) -> None: + bundle = _pack(author).bundle_path + existing = author.parent / "consumer" + existing.mkdir() + (existing / "keep").write_bytes(b"mine") + for options in ({}, {"force": True}, {"skip_verify": True}, {"dry_run": True}): + with pytest.raises(ValueError): + unpack_bundle(bundle, existing, source=True, **options) + assert list(existing.iterdir()) == [existing / "keep"] + assert (existing / "keep").read_bytes() == b"mine" + with pytest.raises(ValueError, match="already exists"): + _pack(author) + + +def test_dry_run_validates_without_writing(author: Path) -> None: + preview = _pack(author, dry_run=True) + assert len(preview.files) == 17 + assert not (author.parent / "dist").exists() + bundle = _pack(author).bundle_path + output = author.parent / "preview" + assert unpack_bundle(bundle, output, source=True, dry_run=True).verified + assert not output.exists() + + +def test_nested_directory_selection_keeps_package_relative_identity(author: Path) -> None: + (author / "factory").mkdir() + (author / "contracts").rename(author / "factory/contracts") + data = load_yaml(author / "apm.yml") + data["resources"] = ["factory/contracts", "checks"] + dump_yaml(data, author / "apm.yml") + bundle = _pack(author).bundle_path + output = author.parent / "restored" + restored = unpack_bundle(bundle, output, source=True) + assert "factory/contracts/phase-0.contract.md" in restored.files + assert (output / "factory/contracts/phase-0.contract.md").read_bytes() == ( + author / "factory/contracts/phase-0.contract.md" + ).read_bytes() + assert not (output / "contracts").exists() + + +def test_copy_race_fails_before_publishing(author: Path, monkeypatch) -> None: + from apm_cli.agent_plugins.assets import AssetInventory + + bundle = _pack(author).bundle_path + original = AssetInventory.open_verified_asset + + @contextmanager + def changed(self, expected): + with original(self, expected) as handle: + if expected.path == "contracts/phase-0.contract.md": + expected.source.path.write_bytes(b"x" * expected.size) + yield handle + + monkeypatch.setattr(AssetInventory, "open_verified_asset", changed) + output = author.parent / "restored" + with pytest.raises(ValueError, match="copy failed integrity"): + unpack_bundle(bundle, output, source=True) + assert not output.exists() + + +@pytest.mark.parametrize("dry_run", [False, True]) +def test_reinventory_cannot_replace_envelope_hash_authority( + author: Path, monkeypatch: pytest.MonkeyPatch, dry_run: bool +) -> None: + from apm_cli.agent_plugins.assets import AssetInventory + from apm_cli.agent_plugins.ir import AgentPluginAsset + from apm_cli.bundle import source_package + + bundle = _pack(author).bundle_path + original_inventory = source_package._source_inventory + + def mutate_then_inventory( + root: Path, + ) -> tuple[APMPackage, AssetInventory, tuple[AgentPluginAsset, ...]]: + (root / "contracts/phase-0.contract.md").write_bytes( + b"CHANGED AFTER ENVELOPE VERIFICATION\n" + ) + return original_inventory(root) + + monkeypatch.setattr(source_package, "_source_inventory", mutate_then_inventory) + output = author.parent / "restored" + with pytest.raises(ValueError, match="inventory does not match"): + unpack_bundle(bundle, output, source=True, dry_run=dry_run) + assert not output.exists() + assert list(output.parent.glob(".apm-restore-*")) == [] + + +@pytest.mark.parametrize("operation", ["pack", "restore"]) +def test_directory_publication_preserves_racing_empty_destination( + author: Path, monkeypatch: pytest.MonkeyPatch, operation: str +) -> None: + from apm_cli.bundle import source_package + + bundle = _pack(author).bundle_path if operation == "restore" else None + output = ( + author.parent / "restored" + if operation == "restore" + else author.parent / "dist/software-factory-1.0.0" + ) + original_publish = source_package.publish_directory_noreplace + occupied_inodes = [] + + def occupy_then_publish(source: Path, destination: Path) -> None: + assert destination == output + destination.mkdir() + occupied_inodes.append(destination.stat().st_ino) + original_publish(source, destination) + + monkeypatch.setattr(source_package, "publish_directory_noreplace", occupy_then_publish) + with pytest.raises(FileExistsError): + if operation == "restore": + assert bundle is not None + unpack_bundle(bundle, output, source=True) + else: + _pack(author) + assert occupied_inodes == [output.stat().st_ino] + assert list(output.iterdir()) == [] + assert list(output.parent.glob(".apm-*")) == [] + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +@pytest.mark.parametrize("occupied_kind", ["file", "directory"]) +def test_archive_publication_preserves_racing_destination( + author: Path, monkeypatch: pytest.MonkeyPatch, archive_format: str, occupied_kind: str +) -> None: + from apm_cli.bundle import source_package + + output = author.parent / "dist" / f"software-factory-1.0.0.{archive_format}" + original_link = os.link + occupied_inodes = [] + + def occupy_then_link(source: Path, destination: Path) -> None: + assert destination == output + if occupied_kind == "directory": + destination.mkdir() + else: + destination.write_bytes(b"consumer-owned") + occupied_inodes.append(destination.stat().st_ino) + original_link(source, destination) + + monkeypatch.setattr(source_package.os, "link", occupy_then_link) + with pytest.raises(FileExistsError): + _pack(author, archive=True, archive_format=archive_format) + assert occupied_inodes == [output.stat().st_ino] + if occupied_kind == "directory": + assert list(output.iterdir()) == [] + else: + assert output.read_bytes() == b"consumer-owned" + assert list(output.parent.glob(".apm-source-*")) == [] + + +@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"]) +def test_source_roundtrip_preserves_private_metadata_mode( + author: Path, archive_format: str +) -> None: + lock = author / "apm.lock.yaml" + check = author / "checks/check-0.sh" + lock.chmod(0o600) + check.chmod(0o755) + expected = (stat.S_IMODE(lock.stat().st_mode), stat.S_IMODE(check.stat().st_mode)) + bundle = _pack(author, archive=True, archive_format=archive_format).bundle_path + output = author.parent / "restored" + unpack_bundle(bundle, output, source=True) + actual = ( + stat.S_IMODE((output / "apm.lock.yaml").stat().st_mode), + stat.S_IMODE((output / "checks/check-0.sh").stat().st_mode), + ) + assert actual == expected + + +def test_source_json_reports_exact_payload_paths(author: Path, monkeypatch) -> None: + monkeypatch.chdir(author) + result = CliRunner().invoke(pack_cmd, ["--format", "apm", "--source", "--dry-run", "--json"]) + assert result.exit_code == 0, result.output + body = json.loads(result.stdout) + assert body["ok"] is True and body["dry_run"] is True + assert body["bundle"]["source"] is True + assert len(body["bundle"]["files"]) == 17 + assert { + "apm.yml", + "apm.lock.yaml", + "checks/check-0.sh", + "contracts/phase-0.contract.md", + } <= set(body["bundle"]["files"]) + assert not (author / "build").exists() diff --git a/tests/unit/scripts/test_architecture_runner.py b/tests/unit/scripts/test_architecture_runner.py index be3c76df6a..966382776d 100644 --- a/tests/unit/scripts/test_architecture_runner.py +++ b/tests/unit/scripts/test_architecture_runner.py @@ -682,6 +682,7 @@ def exiting_import( marketplace-integrations-removed-plugin-lifecycle marketplace-integrations-source-admission marketplace-integrations-source-parsing +marketplace-integrations-source-resources marketplace-integrations-tag-pattern marketplace-integrations-version-precedence mutation_writes.copilot_cli_mcp_paths diff --git a/tests/unit/utils/test_atomic_io.py b/tests/unit/utils/test_atomic_io.py index e8aa82da39..eba7ad45e6 100644 --- a/tests/unit/utils/test_atomic_io.py +++ b/tests/unit/utils/test_atomic_io.py @@ -14,14 +14,17 @@ from __future__ import annotations +import errno +import os import stat import tempfile from pathlib import Path -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest -from apm_cli.utils.atomic_io import atomic_write_text, write_text_lf +from apm_cli.utils.atomic_io import atomic_write_text, publish_directory_noreplace, write_text_lf # Entire module: this is the canonical owner of platform-independent # atomic/LF-safe text writes (microsoft/apm#2233 CRLF-drift class). @@ -30,6 +33,85 @@ pytestmark = pytest.mark.windows_compat +class TestExclusiveDirectoryPublication: + """Native no-replace publication never falls back to replacing a directory.""" + + def test_publishes_complete_directory(self, tmp_path: Path) -> None: + source, destination = tmp_path / "staged", tmp_path / "published" + source.mkdir() + (source / "content").write_bytes(b"verified") + publish_directory_noreplace(source, destination) + assert (destination / "content").read_bytes() == b"verified" + assert not source.exists() + + @pytest.mark.parametrize("occupied_kind", ["directory", "file"]) + def test_occupied_destination_is_unchanged(self, tmp_path: Path, occupied_kind: str) -> None: + source, destination = tmp_path / "staged", tmp_path / "occupied" + source.mkdir() + (source / "content").write_bytes(b"verified") + if occupied_kind == "directory": + destination.mkdir() + else: + destination.write_bytes(b"original") + inode = destination.stat().st_ino + with pytest.raises(OSError): + publish_directory_noreplace(source, destination) + assert destination.stat().st_ino == inode + assert (source / "content").read_bytes() == b"verified" + if occupied_kind == "directory": + assert list(destination.iterdir()) == [] + else: + assert destination.read_bytes() == b"original" + + @pytest.mark.parametrize("platform", ["linux", "darwin", "unsupported"]) + def test_unavailable_capability_never_uses_plain_rename( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str + ) -> None: + from apm_cli.utils import atomic_io + + source, destination = tmp_path / "staged", tmp_path / "published" + source.mkdir() + monkeypatch.setattr(atomic_io, "sys", SimpleNamespace(platform=platform)) + with ( + patch("ctypes.CDLL", return_value=object()), + patch.object(atomic_io.os, "rename") as plain_rename, + pytest.raises(OSError) as error, + ): + publish_directory_noreplace(source, destination) + assert error.value.errno == errno.ENOTSUP + plain_rename.assert_not_called() + assert source.is_dir() and not destination.exists() + + @pytest.mark.parametrize("platform", ["linux", "darwin"]) + @pytest.mark.parametrize("failure", [errno.ENOSYS, errno.ENOTSUP, errno.EXDEV]) + def test_native_failure_propagates_without_fallback( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, platform: str, failure: int + ) -> None: + from apm_cli.utils import atomic_io + + source, destination = tmp_path / "staged", tmp_path / "published" + source.mkdir() + native = Mock(return_value=-1) + library = SimpleNamespace(renameat2=native, renamex_np=native) + monkeypatch.setattr(atomic_io, "sys", SimpleNamespace(platform=platform)) + with ( + patch("ctypes.CDLL", return_value=library), + patch("ctypes.get_errno", return_value=failure), + patch.object(atomic_io.os, "rename") as plain_rename, + pytest.raises(OSError) as error, + ): + publish_directory_noreplace(source, destination) + assert error.value.errno == failure + arguments = ( + (-100, os.fsencode(source), -100, os.fsencode(destination), 1) + if platform == "linux" + else (os.fsencode(source), os.fsencode(destination), 4) + ) + native.assert_called_once_with(*arguments) + plain_rename.assert_not_called() + assert source.is_dir() and not destination.exists() + + class TestAtomicWriteText: """Tests for atomic_write_text().""" diff --git a/tests/utils/source_package.py b/tests/utils/source_package.py new file mode 100644 index 0000000000..ca196182c9 --- /dev/null +++ b/tests/utils/source_package.py @@ -0,0 +1,35 @@ +"""Representative independent contracts and checks for source-package tests.""" + +from pathlib import Path + +from apm_cli.deps.lockfile import LockFile + + +def make_source_package(root: Path) -> dict[str, bytes]: + """Create 15 resources plus exact, commented author metadata.""" + root.mkdir(parents=True) + (root / "apm.yml").write_bytes( + b"# Author metadata must remain byte-identical.\r\n" + b"name: software-factory\r\nversion: '1.0.0'\r\nlicense: MIT\r\n" + b"resources: [contracts, checks]\r\n" + b"targets: [copilot]\r\n" + b"scripts:\r\n postinstall: 'touch EXECUTED'\r\n" + b"dependencies: {}\r\n" + ) + (root / "apm.lock.yaml").write_bytes( + b"# Preserve this original lock, not the envelope lock.\r\n" + + LockFile().to_yaml().replace("\n", "\r\n").encode() + ) + for index in range(10): + path = root / f"contracts/phase-{index}.contract.md" + path.parent.mkdir(exist_ok=True) + path.write_bytes(f"# Independent phase {index}\r\nInput -> output.\r\n".encode()) + for index in range(5): + path = root / f"checks/check-{index}.sh" + path.parent.mkdir(exist_ok=True) + path.write_bytes(b"#!/bin/sh\nprintf forbidden > EXECUTED\n") + return { + path.relative_to(root).as_posix(): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + }