feat: harden download and install security
This commit is contained in:
parent
f8ffb95376
commit
af13e98eb3
33 changed files with 1517 additions and 46 deletions
62
.architecture/overview.md
Normal file
62
.architecture/overview.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Architecture Overview
|
||||
|
||||
## Workspace Shape
|
||||
|
||||
`aim` is a Rust workspace with two main crates:
|
||||
|
||||
- `crates/aim-core`: source normalization, provider adapters, install/update planning, payload installation, registry persistence, and desktop integration.
|
||||
- `crates/aim-cli`: argument parsing, config loading, terminal UX, prompting, progress reporting, and summary rendering.
|
||||
|
||||
The split keeps product logic in `aim-core` so additional frontends can reuse the same install and update pipeline.
|
||||
|
||||
## Core Flow
|
||||
|
||||
The main execution path is:
|
||||
|
||||
1. Parse CLI input and load runtime config in `aim-cli`.
|
||||
2. Resolve the query into a normalized source in `aim-core`.
|
||||
3. Build an add or update plan through provider adapters and artifact selection.
|
||||
4. Download the selected AppImage into a staged path.
|
||||
5. Verify integrity metadata when available.
|
||||
6. Commit the payload into the managed install location.
|
||||
7. Write desktop integration artifacts and refresh helper caches.
|
||||
8. Persist registry state atomically.
|
||||
|
||||
## Source And Provider Model
|
||||
|
||||
Supported source classes currently include:
|
||||
|
||||
- GitHub repository and release forms
|
||||
- GitLab repository forms
|
||||
- AppImageHub item forms
|
||||
- SourceForge release and download forms
|
||||
- direct URLs
|
||||
- local file imports
|
||||
|
||||
Provider-specific resolution lives in `crates/aim-core/src/adapters` and `crates/aim-core/src/source`.
|
||||
|
||||
## Security Hardening State
|
||||
|
||||
The current workspace enforces the following download and install boundaries:
|
||||
|
||||
- user-supplied `http://` inputs are rejected by default
|
||||
- runtime opt-in is available through `allow_http = true`
|
||||
- that opt-in applies only to user-supplied sources, including update flows derived from stored direct HTTP origins
|
||||
- AppImageHub provider-returned download URLs must remain HTTPS
|
||||
- AppImageHub MD5 metadata is verified as weak integrity before payload commit
|
||||
- desktop entry display names are sanitized to prevent newline and control-character field injection
|
||||
- stable identifiers that normalize to empty or contain `..` are rejected
|
||||
|
||||
The remaining deferred AppImageHub host-trust concern is tracked in `security-issues.md`.
|
||||
|
||||
## Persistence And Integration
|
||||
|
||||
- Registry writes are atomic and live under the registry store implementation in `aim-core`.
|
||||
- Managed payload, desktop entry, and icon paths are resolved from install policy and scope.
|
||||
- Desktop integration refresh uses external helpers when available and now supports env-gated audit logging through `AIM_DEBUG_EXTERNAL_HELPERS=1`.
|
||||
|
||||
## Planning And Audit Artifacts
|
||||
|
||||
- implementation plans live under `.plans/`
|
||||
- audit reports live under `.audits/`
|
||||
- architecture state and tracked security issues live under `.architecture/`
|
||||
32
.architecture/security-issues.md
Normal file
32
.architecture/security-issues.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Security Issues
|
||||
|
||||
## AppImageHub Download Host Trust
|
||||
|
||||
**Status:** Open
|
||||
**Severity:** High
|
||||
**Area:** Provider trust / supply chain
|
||||
|
||||
### Summary
|
||||
|
||||
`aim` now enforces HTTPS for AppImageHub provider-returned download URLs, but it does not yet enforce a host trust policy or allowlist for those returned URLs.
|
||||
|
||||
### Current Mitigation
|
||||
|
||||
- AppImageHub download URLs must use `https://`
|
||||
- insecure user-supplied HTTP policy is handled separately through `allow_http`
|
||||
|
||||
### Remaining Gap
|
||||
|
||||
A compromised or unexpected AppImageHub API response could still direct downloads to an arbitrary HTTPS host. Transport encryption alone does not establish publisher trust.
|
||||
|
||||
### Deferred Follow-Up
|
||||
|
||||
Future hardening should add one of:
|
||||
|
||||
- a fixed allowlist of expected AppImageHub download hosts
|
||||
- a configurable host trust policy
|
||||
- stronger publisher verification metadata if AppImageHub exposes it
|
||||
|
||||
### Notes
|
||||
|
||||
This issue is intentionally tracked separately from the immediate HTTPS enforcement work so the current hardening tranche can reduce risk without trying to solve the full provider trust model in one pass.
|
||||
205
.audits/2026-03-21T20-08-04Z-post-appimagehub-security-audit.md
Normal file
205
.audits/2026-03-21T20-08-04Z-post-appimagehub-security-audit.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Repository Audit: aim Post-AppImageHub
|
||||
|
||||
**Timestamp:** 2026-03-21T20:08:04Z
|
||||
**Audited commit:** `f8ffb953763ceab41bb97a26afae52df7e31d539`
|
||||
**Branch at audit time:** `main`
|
||||
**Scope:** repository-level functionality gaps, engineering holes, and security concerns after the AppImageHub provider merge
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase is in materially better shape than the first generated audit suggested. Two previously reported items were false positives and have been removed:
|
||||
|
||||
- registry writes are already atomic via temp-file-plus-rename in [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs)
|
||||
- CLI search coverage already exists in [crates/aim-cli/tests/search_cli.rs](crates/aim-cli/tests/search_cli.rs)
|
||||
|
||||
That said, there are still real pre-release security gaps.
|
||||
|
||||
### Verified top findings
|
||||
|
||||
1. Critical: insecure `http://` downloads are accepted by the direct URL path and some provider parsing paths
|
||||
2. Critical: AppImageHub download URLs from the upstream API are trusted without a transport or trust-policy check
|
||||
3. High: desktop entry generation accepts unsanitized display names, allowing newline-based desktop-file field injection
|
||||
4. Medium: AppImageHub exposes MD5 metadata, but installs do not validate any AppImageHub checksum at all
|
||||
5. Low: stable ID normalization permits `..`, so explicit path hardening is still missing as defense in depth
|
||||
|
||||
### Overall verdict
|
||||
|
||||
Not production-ready for hostile or semi-hostile networks. The dominant work left is security hardening, not core feature breadth.
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### SEC-001: Insecure HTTP Downloads Accepted
|
||||
|
||||
**Severity:** Critical
|
||||
**Category:** Security
|
||||
**Why it matters:** Any accepted `http://` download enables trivial man-in-the-middle payload replacement.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- Direct URL classification accepts both HTTP and HTTPS in [crates/aim-core/src/source/input.rs](crates/aim-core/src/source/input.rs#L57)
|
||||
- SourceForge parsing still explicitly accepts HTTP project URLs in [crates/aim-core/src/adapters/sourceforge.rs](crates/aim-core/src/adapters/sourceforge.rs#L219)
|
||||
|
||||
**Impact**
|
||||
|
||||
- A user can install a payload fetched over plaintext transport.
|
||||
- An attacker on the network path can replace the AppImage before it is executed.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Reject `http://` sources by default.
|
||||
- If you want an escape hatch, require an explicit insecure override such as `--allow-insecure` and print a strong warning.
|
||||
- Add regression tests for both rejection and override behavior.
|
||||
|
||||
### SEC-002: AppImageHub Download URLs Are Trusted Without Validation
|
||||
|
||||
**Severity:** Critical
|
||||
**Category:** Security
|
||||
**Why it matters:** The AppImageHub API is currently treated as authoritative for the final download URL, but the returned URL is not checked before being handed to the downloader.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- AppImageHub download links are accepted verbatim in [crates/aim-core/src/source/appimagehub.rs](crates/aim-core/src/source/appimagehub.rs#L349)
|
||||
- The selected AppImageHub download URL is passed directly into the install artifact candidate in [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L181)
|
||||
|
||||
**Impact**
|
||||
|
||||
- If the upstream API is compromised or returns an unexpected domain, `aim` will fetch from that location.
|
||||
- Combined with SEC-001, this becomes a clear supply-chain hole.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Enforce HTTPS on provider-returned URLs.
|
||||
- Add a host trust policy for AppImageHub downloads, either hard-coded to known domains or configurable.
|
||||
- Add tests for invalid schemes and untrusted hosts.
|
||||
|
||||
### SEC-003: Desktop Entry Field Injection Through Unsanitized Display Name
|
||||
|
||||
**Severity:** High
|
||||
**Category:** Security
|
||||
**Why it matters:** The generated `.desktop` file interpolates `display_name` directly into the file body. The practical risk is newline injection, not shell metacharacters in the `Name` field.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- Desktop entries are rendered by string interpolation in [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L687)
|
||||
- `display_name` can come from provider-controlled metadata through identity resolution in [crates/aim-core/src/app/identity.rs](crates/aim-core/src/app/identity.rs#L27)
|
||||
|
||||
**Impact**
|
||||
|
||||
- A malicious provider name containing `\nExec=...` or another desktop-entry key can inject extra fields into the generated launcher.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Strip or reject newlines and control characters before writing desktop entries.
|
||||
- Consider validating display names against a conservative allowlist for launcher generation.
|
||||
- Add a regression test proving that a name containing `\nExec=evil` cannot alter the resulting desktop file.
|
||||
|
||||
### SEC-004: AppImageHub Checksum Metadata Is Parsed But Not Used
|
||||
|
||||
**Severity:** Medium
|
||||
**Category:** Security / Integrity
|
||||
**Why it matters:** AppImageHub metadata includes MD5 values, but the current flow intentionally drops them and performs no integrity check for AppImageHub downloads.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- MD5 fields are stored on AppImageHub download records in [crates/aim-core/src/source/appimagehub.rs](crates/aim-core/src/source/appimagehub.rs#L14)
|
||||
- AppImageHub installs set `trusted_checksum: None` in [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L184)
|
||||
- The checksum verifier only handles the existing trusted checksum path in [crates/aim-core/src/integration/install.rs](crates/aim-core/src/integration/install.rs#L169)
|
||||
|
||||
**Impact**
|
||||
|
||||
- AppImageHub downloads have no post-download integrity signal at all.
|
||||
- This does not create remote code execution by itself, but it weakens the install pipeline substantially.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Add explicit support for provider MD5 verification as a separate integrity path, or refuse to advertise checksum-backed trust when only MD5 is available.
|
||||
- If you keep MD5 support, label it as weak integrity rather than strong trust.
|
||||
|
||||
### GAP-001: Stable ID Path Hardening Is Missing
|
||||
|
||||
**Severity:** Low
|
||||
**Category:** Security / Hardening
|
||||
**Why it matters:** Stable IDs are used in path construction. The normalizer preserves `.` characters, so `..` survives normalization.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- Identifier normalization preserves `.` in [crates/aim-core/src/app/identity.rs](crates/aim-core/src/app/identity.rs#L62)
|
||||
- Stable IDs are interpolated into installation paths in [crates/aim-core/src/app/add.rs](crates/aim-core/src/app/add.rs#L424)
|
||||
|
||||
**Impact**
|
||||
|
||||
- I did not verify a full exploit path from current command flows, so this is not an immediate blocker.
|
||||
- It is still worth closing because the path join contract currently relies on upstream callers never producing `..`.
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Explicitly reject `..` in normalized stable IDs.
|
||||
- Add a path containment assertion before final install paths are committed.
|
||||
|
||||
### OBS-001: No Audit Trail For External Refresh Commands
|
||||
|
||||
**Severity:** Low
|
||||
**Category:** Observability
|
||||
**Why it matters:** Desktop integration helpers are executed, but successful invocations are not logged anywhere useful for incident reconstruction.
|
||||
|
||||
**Evidence**
|
||||
|
||||
- Helper execution happens in [crates/aim-core/src/integration/refresh.rs](crates/aim-core/src/integration/refresh.rs#L17)
|
||||
|
||||
**Recommendation**
|
||||
|
||||
- Add debug logging for helper name, args, and exit status.
|
||||
|
||||
## False Positives Removed
|
||||
|
||||
These were checked and should not be treated as open findings:
|
||||
|
||||
- Registry writes are atomic: [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs#L24)
|
||||
- CLI search tests do exist, including AppImageHub coverage: [crates/aim-cli/tests/search_cli.rs](crates/aim-cli/tests/search_cli.rs#L1)
|
||||
|
||||
## Positive Findings
|
||||
|
||||
- Checksum verification for the existing trusted-checksum path is implemented and tested in [crates/aim-core/src/integration/install.rs](crates/aim-core/src/integration/install.rs#L169) and [crates/aim-core/tests/checksum_verification.rs](crates/aim-core/tests/checksum_verification.rs)
|
||||
- Update rollback exists and is exercised in [crates/aim-core/src/app/update.rs](crates/aim-core/src/app/update.rs) and related tests
|
||||
- Registry persistence is already atomic in [crates/aim-core/src/registry/store.rs](crates/aim-core/src/registry/store.rs)
|
||||
- Search has both core-level and CLI-level coverage in [crates/aim-core/tests/appimagehub_search.rs](crates/aim-core/tests/appimagehub_search.rs) and [crates/aim-cli/tests/search_cli.rs](crates/aim-cli/tests/search_cli.rs)
|
||||
|
||||
## Missing Functionality / Product Holes
|
||||
|
||||
The most meaningful missing functionality from this pass is security functionality rather than user-facing commands:
|
||||
|
||||
- no secure/insecure transport policy split for downloads
|
||||
- no provider trust policy for AppImageHub download hosts
|
||||
- no provider-specific integrity verification for AppImageHub artifacts
|
||||
- no adversarial-input test suite covering malicious provider metadata and malformed provider responses
|
||||
|
||||
I did not verify any major missing core CLI flow beyond those hardening gaps in this pass.
|
||||
|
||||
## Recommended Priority Order
|
||||
|
||||
### Immediate
|
||||
|
||||
1. Reject insecure HTTP downloads by default
|
||||
2. Validate AppImageHub download URLs before download
|
||||
3. Sanitize display names before desktop-entry generation
|
||||
|
||||
### Short-Term
|
||||
|
||||
4. Add AppImageHub integrity verification semantics
|
||||
5. Add targeted security regression tests for malicious URLs and malicious display names
|
||||
|
||||
### Medium-Term
|
||||
|
||||
6. Harden stable ID path safety
|
||||
7. Add structured logging for external helper execution
|
||||
|
||||
## Residual Test Gaps
|
||||
|
||||
- No regression test proving HTTP sources are rejected
|
||||
- No regression test proving AppImageHub URLs are scheme/host validated
|
||||
- No regression test proving newline-bearing display names cannot inject desktop-entry fields
|
||||
- No adversarial fixture coverage for malformed or malicious AppImageHub XML payloads
|
||||
|
||||
## Audit Conclusion
|
||||
|
||||
This is a solid implementation base with real progress on provider breadth, rollback, and verification, but it still has several concrete security holes in the download trust boundary. Those should be treated as the next tranche before calling the installer production-safe.
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# Security Hardening Config And Integrity Design
|
||||
|
||||
## Summary
|
||||
|
||||
This change hardens the `aim` install pipeline around transport trust, desktop-entry generation, provider integrity checks, and path safety. The approved shape is narrow by design: add `allow_http` to `config.toml` with a default of `false`, apply it only to user-supplied HTTP sources, keep AppImageHub provider-returned downloads HTTPS-only for now, track the broader AppImageHub host-trust issue in `.architecture/security-issues.md`, sanitize launcher display names before writing `.desktop` files, enforce AppImageHub checksum verification semantics, and harden stable-ID-derived install paths.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add `allow_http` to the user-facing runtime config with a secure default of `false`.
|
||||
- Apply `allow_http` only to user-supplied source URLs and explicit HTTP provider inputs.
|
||||
- Keep AppImageHub provider-returned download URLs on a stricter HTTPS-only policy.
|
||||
- Record the unresolved AppImageHub host-trust issue in `.architecture/security-issues.md`.
|
||||
- Prevent newline and control-character injection in generated desktop entries.
|
||||
- Enforce AppImageHub checksum validation rather than silently dropping provider checksum metadata.
|
||||
- Explicitly reject or contain dangerous stable IDs such as `..`.
|
||||
- Add targeted adversarial regression coverage for the new hardening paths.
|
||||
- Add lightweight audit logging around external helper execution.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No global provider trust framework beyond the minimum AppImageHub note and HTTPS enforcement in this slice.
|
||||
- No redesign of the theme config loader split unless it becomes necessary to thread runtime config.
|
||||
- No cryptographic reclassification of AppImageHub MD5 into the existing trusted SHA-512 path.
|
||||
- No broader provider security audit implementation outside the issues already approved here.
|
||||
- No new CLI flags unless the implementation later proves config-only is insufficient.
|
||||
|
||||
## Approaches
|
||||
|
||||
### Option 1: Global `allow_http`
|
||||
|
||||
This would make a single config flag disable HTTPS enforcement everywhere, including provider-returned URLs. It is easy to wire, but it weakens the trust boundary too broadly and makes one local preference affect third-party provider behavior in ways that are hard to reason about.
|
||||
|
||||
### Option 2: User-Input-Only `allow_http`
|
||||
|
||||
This is the approved design. `allow_http` applies only to user-supplied direct URLs and explicit HTTP provider inputs such as legacy SourceForge HTTP forms. Provider-returned download URLs remain subject to provider-specific policy. This keeps the config narrow and predictable while still giving advanced users an escape hatch for manual HTTP sources.
|
||||
|
||||
### Option 3: Split Security Flags Per Source Class
|
||||
|
||||
This would introduce separate toggles for direct URLs, provider URLs, and possibly per-provider policy. It is the most explicit shape, but it creates unnecessary configuration surface for the current repo.
|
||||
|
||||
## Approved Design
|
||||
|
||||
### Config Model
|
||||
|
||||
Add `allow_http = false` to the existing runtime `config.toml` model used by `crates/aim-cli/src/config.rs`. This config is already loaded in `main.rs` for rendering behavior, but the current dispatch path does not receive it. The implementation should thread the loaded runtime config into dispatch rather than adding an unrelated second config lookup.
|
||||
|
||||
The existing theme-only loader under `crates/aim-cli/src/cli/config.rs` is not the place for this setting. This change should preserve that split unless unification becomes necessary later.
|
||||
|
||||
### Transport Policy
|
||||
|
||||
#### User-Supplied Sources
|
||||
|
||||
User-supplied HTTP sources are:
|
||||
|
||||
- raw `http://...` direct URLs
|
||||
- explicit HTTP provider forms that the source parsing layer currently accepts, such as SourceForge HTTP URLs
|
||||
|
||||
These should be rejected by default when `allow_http = false` and allowed only when `allow_http = true`.
|
||||
|
||||
#### Provider-Returned URLs
|
||||
|
||||
Provider-returned URLs are not covered by `allow_http` in this slice. In particular, AppImageHub download URLs returned by the provider transport should be enforced as HTTPS-only regardless of user config.
|
||||
|
||||
This distinction preserves the user’s ability to opt into an insecure source they typed deliberately without silently expanding trust for provider-sourced URLs.
|
||||
|
||||
### AppImageHub Security Handling
|
||||
|
||||
AppImageHub gets two separate treatments:
|
||||
|
||||
1. **Immediate enforcement now**
|
||||
- reject non-HTTPS AppImageHub download URLs
|
||||
- fail resolution or add-plan construction with a provider-specific error
|
||||
|
||||
2. **Deferred broader issue**
|
||||
- document the missing host-trust / domain allowlist model in `.architecture/security-issues.md`
|
||||
- do not try to solve the broader provider trust framework in this slice
|
||||
|
||||
This satisfies the approved direction: the HTTPS rule halves the immediate risk, while the architectural gap remains visible and explicit.
|
||||
|
||||
### Desktop Entry Sanitization
|
||||
|
||||
The `.desktop` renderer should sanitize display names before interpolation. The key requirement is to prevent field injection, so the sanitation policy should at minimum:
|
||||
|
||||
- strip or reject `\n` and `\r`
|
||||
- collapse other control characters
|
||||
- preserve normal display names for legitimate apps
|
||||
|
||||
The sanitation should happen close to desktop-entry generation rather than mutating the stored display name globally.
|
||||
|
||||
### AppImageHub Checksum Enforcement
|
||||
|
||||
The current AppImageHub path stores MD5 metadata but drops it before installation. This slice should stop silently ignoring it.
|
||||
|
||||
Because the existing `trusted_checksum` path is a SHA-512 base64 trust mechanism, AppImageHub MD5 should not be forced into that same contract. Instead, add a provider-specific integrity verification path that:
|
||||
|
||||
- computes MD5 for the staged payload when AppImageHub provides one
|
||||
- fails installation on mismatch
|
||||
- treats MD5 as weaker integrity metadata, not as equivalent to the existing trusted checksum model
|
||||
|
||||
This preserves conceptual clarity: strong trusted checksums remain one mechanism, while provider-specific MD5 integrity checks are a separate, weaker guardrail.
|
||||
|
||||
### Stable ID Path Hardening
|
||||
|
||||
Stable IDs are interpolated into payload, desktop, and icon paths. The normalizer currently preserves `.` characters, so `..` survives.
|
||||
|
||||
The approved change is:
|
||||
|
||||
- reject normalized IDs containing `..`
|
||||
- add a final containment assertion or validation on managed paths before installation proceeds
|
||||
|
||||
This is defense in depth even if current command paths do not expose an easy exploit.
|
||||
|
||||
### External Command Audit Logging
|
||||
|
||||
The desktop integration refresh path should log helper execution at debug level, including command name, args, and exit status when available. This is low-priority observability work, but it belongs in the same hardening tranche because it improves forensic clarity.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Rejected HTTP user inputs should fail with a clear security-oriented message explaining that HTTP is disabled unless `allow_http = true` is set.
|
||||
- Rejected AppImageHub download URLs should fail with a provider-specific security message rather than a generic parse failure.
|
||||
- Desktop-entry sanitation should be non-disruptive where possible; reject only if the sanitized output would become invalid or empty.
|
||||
- AppImageHub checksum mismatch should fail install before commit, matching the spirit of existing checksum enforcement.
|
||||
- Stable-ID hardening should fail deterministically with an explicit invalid-identity error rather than produce a malformed path.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Config And Transport Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `allow_http` defaulting to `false`
|
||||
- config override with `allow_http = true`
|
||||
- direct `http://` URL rejection by default
|
||||
- direct `http://` URL success when config enables it
|
||||
- SourceForge HTTP behavior matching the same policy
|
||||
- AppImageHub provider-returned `http://` URL rejection even when `allow_http = true`
|
||||
|
||||
### Desktop Entry Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- newline-bearing display names being sanitized before render
|
||||
- control characters not appearing in generated desktop entries
|
||||
- ordinary display names remaining unchanged
|
||||
|
||||
### Integrity Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- valid AppImageHub MD5 succeeds
|
||||
- invalid AppImageHub MD5 fails before commit
|
||||
- missing AppImageHub MD5 continues with the current provider behavior
|
||||
|
||||
### Path Hardening Tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- normalized identifiers containing `..` being rejected
|
||||
- installation path validation refusing escape outside managed roots
|
||||
|
||||
### Observability Tests
|
||||
|
||||
If practical, add tests around helper invocation logging or at least unit-test the formatting / branch behavior around helper execution outcomes.
|
||||
|
||||
## Delivery Notes
|
||||
|
||||
- Do not conflate AppImageHub MD5 with the existing trusted SHA-512 checksum contract.
|
||||
- Keep `allow_http` policy narrow and explicit.
|
||||
- Prefer plumbing the already-loaded runtime config into dispatch rather than inventing another config read path.
|
||||
- Track deferred provider host trust in `.architecture/security-issues.md`, not hidden in TODOs.
|
||||
- This slice is security-hardening-first; avoid mixing in unrelated product work.
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
# Security Hardening Config And Integrity Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add secure-by-default HTTP policy controls, enforce AppImageHub HTTPS and checksum handling, sanitize desktop entries, harden stable-ID path usage, and document the remaining AppImageHub trust issue.
|
||||
|
||||
**Architecture:** Extend the existing runtime `CliConfig` with `allow_http`, thread that config into dispatch and add/install planning, keep provider-returned AppImageHub URLs on a stricter HTTPS-only path, add a provider-specific MD5 integrity check distinct from the existing trusted checksum mechanism, and tighten install-time path and desktop-entry generation at the boundary where files are written.
|
||||
|
||||
**Tech Stack:** Rust workspace, Cargo tests, TOML config loading, existing install pipeline, fixture-backed provider tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Record the approved security shape in repo docs
|
||||
|
||||
**Files:**
|
||||
- Create: `.architecture/security-issues.md`
|
||||
- Modify: `README.md`
|
||||
- Reference: `.audits/2026-03-21T20-08-04Z-post-appimagehub-security-audit.md`
|
||||
|
||||
**Step 1: Write the security issues note**
|
||||
|
||||
Create `.architecture/security-issues.md` with:
|
||||
|
||||
- a short description of the AppImageHub host-trust gap
|
||||
- current mitigation: AppImageHub downloads must be HTTPS
|
||||
- deferred work: domain allowlist / provider trust policy
|
||||
- status label such as `open`
|
||||
|
||||
**Step 2: Update the README security/config section**
|
||||
|
||||
Document:
|
||||
|
||||
- `allow_http = false` default
|
||||
- `allow_http = true` only affects user-supplied HTTP sources
|
||||
- provider-returned AppImageHub URLs remain HTTPS-only
|
||||
|
||||
**Step 3: Verify docs exist and read clearly**
|
||||
|
||||
Run: `rg -n "allow_http|AppImageHub|security" README.md .architecture/security-issues.md`
|
||||
Expected: matching lines in both files
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .architecture/security-issues.md README.md
|
||||
git commit -m "docs: record download security policy"
|
||||
```
|
||||
|
||||
### Task 2: Add `allow_http` to runtime config and thread it into dispatch
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-cli/src/config.rs`
|
||||
- Modify: `crates/aim-cli/src/main.rs`
|
||||
- Modify: `crates/aim-cli/src/lib.rs`
|
||||
- Test: `crates/aim-cli/tests/config_loading.rs`
|
||||
|
||||
**Step 1: Write the failing config tests**
|
||||
|
||||
Add tests covering:
|
||||
|
||||
- default config has `allow_http == false`
|
||||
- config file with `allow_http = true` parses and loads correctly
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-cli --test config_loading`
|
||||
Expected: FAIL because `allow_http` does not exist yet
|
||||
|
||||
**Step 3: Add the config field**
|
||||
|
||||
Update `CliConfig` with:
|
||||
|
||||
- `allow_http: bool`
|
||||
- `#[serde(default)]`
|
||||
- default value `false`
|
||||
|
||||
**Step 4: Thread config into dispatch**
|
||||
|
||||
Refactor the dispatch entrypoints so the already-loaded runtime config is available during query resolution and install planning.
|
||||
|
||||
Preferred shape:
|
||||
|
||||
- add `dispatch_with_reporter_and_config(...)`
|
||||
- keep existing `dispatch_with_reporter(...)` delegating to default config if needed for compatibility
|
||||
- update `main.rs` to call the config-aware path
|
||||
|
||||
**Step 5: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-cli --test config_loading`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-cli/src/config.rs crates/aim-cli/src/main.rs crates/aim-cli/src/lib.rs crates/aim-cli/tests/config_loading.rs
|
||||
git commit -m "feat: add allow_http runtime config"
|
||||
```
|
||||
|
||||
### Task 3: Enforce HTTP policy for user-supplied sources only
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-core/src/source/input.rs`
|
||||
- Modify: `crates/aim-core/src/app/add.rs`
|
||||
- Modify: `crates/aim-cli/src/lib.rs`
|
||||
- Test: `crates/aim-core/tests/query_resolution.rs`
|
||||
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||
|
||||
**Step 1: Write the failing behavior tests**
|
||||
|
||||
Add tests covering:
|
||||
|
||||
- direct `http://example.com/app.AppImage` fails by default
|
||||
- the same input succeeds when `allow_http = true`
|
||||
- explicit SourceForge `http://...` inputs follow the same rule
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-cli --test end_to_end_cli`
|
||||
Expected: FAIL because HTTP is currently accepted unconditionally
|
||||
|
||||
**Step 3: Add an explicit HTTP policy check**
|
||||
|
||||
Implement a narrow policy helper that is evaluated only for user-supplied source inputs before add/install proceeds.
|
||||
|
||||
Requirements:
|
||||
|
||||
- reject insecure HTTP when config disallows it
|
||||
- preserve HTTPS behavior unchanged
|
||||
- do not let this config affect provider-returned URLs
|
||||
|
||||
**Step 4: Surface a clear security error**
|
||||
|
||||
Ensure the user sees a message equivalent to:
|
||||
|
||||
- `insecure HTTP sources are disabled; set allow_http = true to permit them`
|
||||
|
||||
**Step 5: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-cli --test end_to_end_cli`
|
||||
Expected: PASS with both rejection and opt-in cases covered
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-core/src/source/input.rs crates/aim-core/src/app/add.rs crates/aim-cli/src/lib.rs crates/aim-core/tests/query_resolution.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||
git commit -m "feat: enforce user http policy"
|
||||
```
|
||||
|
||||
### Task 4: Enforce HTTPS for AppImageHub provider-returned downloads
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-core/src/source/appimagehub.rs`
|
||||
- Modify: `crates/aim-core/src/adapters/appimagehub.rs`
|
||||
- Modify: `crates/aim-core/src/app/add.rs`
|
||||
- Test: `crates/aim-core/tests/adapter_contract.rs`
|
||||
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||
|
||||
**Step 1: Write the failing AppImageHub tests**
|
||||
|
||||
Add a fixture-backed case where AppImageHub returns an `http://` download URL.
|
||||
|
||||
Expected result:
|
||||
|
||||
- install planning or resolution fails with a provider-specific security error
|
||||
- this remains true even when `allow_http = true`
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-core --test adapter_contract`
|
||||
Expected: FAIL because AppImageHub URLs are currently accepted verbatim
|
||||
|
||||
**Step 3: Add AppImageHub URL validation**
|
||||
|
||||
Validate provider-returned AppImageHub download URLs for:
|
||||
|
||||
- HTTPS scheme required
|
||||
- clear provider-specific error path
|
||||
|
||||
Do not add the broader host allowlist in this task.
|
||||
|
||||
**Step 4: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-core --test adapter_contract && cargo test --package aim-cli --test end_to_end_cli`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-core/src/source/appimagehub.rs crates/aim-core/src/adapters/appimagehub.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/adapter_contract.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||
git commit -m "fix: require https for appimagehub downloads"
|
||||
```
|
||||
|
||||
### Task 5: Sanitize desktop entry display names
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-core/src/app/add.rs`
|
||||
- Test: `crates/aim-core/tests/install_integration.rs`
|
||||
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||
|
||||
**Step 1: Write the failing desktop-entry tests**
|
||||
|
||||
Add tests covering:
|
||||
|
||||
- display name containing `\nExec=evil` does not inject a second field
|
||||
- display name containing control characters renders safely
|
||||
- normal display names still render as expected
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-core --test install_integration`
|
||||
Expected: FAIL because desktop entry output currently interpolates raw display names
|
||||
|
||||
**Step 3: Implement minimal sanitation**
|
||||
|
||||
Add a helper near desktop entry rendering that:
|
||||
|
||||
- strips `\r` and `\n`
|
||||
- replaces other control characters with spaces or removes them
|
||||
- preserves ordinary printable text
|
||||
|
||||
Use the sanitized value only for desktop-entry rendering, not for mutating the stored app record.
|
||||
|
||||
**Step 4: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-core --test install_integration`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-core/src/app/add.rs crates/aim-core/tests/install_integration.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||
git commit -m "fix: sanitize desktop entry names"
|
||||
```
|
||||
|
||||
### Task 6: Enforce AppImageHub MD5 integrity checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml`
|
||||
- Modify: `crates/aim-core/Cargo.toml`
|
||||
- Modify: `crates/aim-core/src/domain/artifact.rs` or the existing artifact type definition file
|
||||
- Modify: `crates/aim-core/src/app/add.rs`
|
||||
- Modify: `crates/aim-core/src/integration/install.rs`
|
||||
- Test: `crates/aim-core/tests/checksum_verification.rs`
|
||||
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||
|
||||
**Step 1: Identify the artifact checksum type location**
|
||||
|
||||
Before editing, confirm where `ArtifactCandidate` is defined and where a provider-specific MD5 field should live.
|
||||
|
||||
**Step 2: Write the failing integrity tests**
|
||||
|
||||
Add tests covering:
|
||||
|
||||
- AppImageHub install succeeds with matching MD5 fixture data
|
||||
- AppImageHub install fails before commit on MD5 mismatch
|
||||
- AppImageHub install still succeeds when no MD5 exists
|
||||
|
||||
**Step 3: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-core --test checksum_verification`
|
||||
Expected: FAIL because AppImageHub MD5 is currently ignored
|
||||
|
||||
**Step 4: Add a separate weak-integrity field/path**
|
||||
|
||||
Implement a provider-specific integrity path distinct from `trusted_checksum`.
|
||||
|
||||
Requirements:
|
||||
|
||||
- store the provider MD5 on the artifact candidate or equivalent install request
|
||||
- verify it after staging and before commit
|
||||
- do not overload the existing trusted SHA-512 semantics
|
||||
|
||||
**Step 5: Add any needed dependency explicitly**
|
||||
|
||||
If an MD5 crate is required, add it at the workspace and crate level.
|
||||
|
||||
**Step 6: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-core --test checksum_verification && cargo test --package aim-cli --test end_to_end_cli`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add Cargo.toml crates/aim-core/Cargo.toml crates/aim-core/src/app/add.rs crates/aim-core/src/integration/install.rs crates/aim-core/tests/checksum_verification.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||
git commit -m "feat: verify appimagehub md5 integrity"
|
||||
```
|
||||
|
||||
### Task 7: Harden stable IDs and managed path containment
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-core/src/app/identity.rs`
|
||||
- Modify: `crates/aim-core/src/app/add.rs`
|
||||
- Test: `crates/aim-core/tests/identity_resolution.rs`
|
||||
- Test: `crates/aim-core/tests/install_paths.rs`
|
||||
|
||||
**Step 1: Write the failing hardening tests**
|
||||
|
||||
Add tests covering:
|
||||
|
||||
- identifiers normalizing to `..` are rejected
|
||||
- managed install paths do not escape managed roots
|
||||
|
||||
**Step 2: Run the focused tests to verify failure**
|
||||
|
||||
Run: `cargo test --package aim-core --test identity_resolution --test install_paths`
|
||||
Expected: FAIL because `..` currently survives normalization and there is no explicit containment check
|
||||
|
||||
**Step 3: Implement identity and path validation**
|
||||
|
||||
Add:
|
||||
|
||||
- explicit normalized-ID rejection for `..`
|
||||
- path containment validation before install proceeds
|
||||
|
||||
Keep the implementation minimal and deterministic.
|
||||
|
||||
**Step 4: Run the focused tests to verify pass**
|
||||
|
||||
Run: `cargo test --package aim-core --test identity_resolution --test install_paths`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-core/src/app/identity.rs crates/aim-core/src/app/add.rs crates/aim-core/tests/identity_resolution.rs crates/aim-core/tests/install_paths.rs
|
||||
git commit -m "fix: harden stable id paths"
|
||||
```
|
||||
|
||||
### Task 8: Add external helper audit logging and adversarial regression coverage
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aim-core/src/integration/refresh.rs`
|
||||
- Modify: `crates/aim-core/src/source/appimagehub.rs`
|
||||
- Test: `crates/aim-core/tests/adapter_contract.rs`
|
||||
- Test: `crates/aim-core/tests/install_integration.rs`
|
||||
- Test: `crates/aim-cli/tests/end_to_end_cli.rs`
|
||||
|
||||
**Step 1: Write the failing or missing regression tests**
|
||||
|
||||
Add adversarial cases for:
|
||||
|
||||
- malformed AppImageHub XML or missing fields handled cleanly
|
||||
- malicious display names in fixture-backed install flows
|
||||
- helper execution paths producing expected warnings/loggable branches
|
||||
|
||||
**Step 2: Implement minimal logging**
|
||||
|
||||
Add debug-level logging around helper execution in `refresh.rs`.
|
||||
|
||||
**Step 3: Run focused tests**
|
||||
|
||||
Run: `cargo test --package aim-core --test adapter_contract --test install_integration && cargo test --package aim-cli --test end_to_end_cli`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/aim-core/src/integration/refresh.rs crates/aim-core/src/source/appimagehub.rs crates/aim-core/tests/adapter_contract.rs crates/aim-core/tests/install_integration.rs crates/aim-cli/tests/end_to_end_cli.rs
|
||||
git commit -m "test: cover security edge cases"
|
||||
```
|
||||
|
||||
### Task 9: Full verification and final docs pass
|
||||
|
||||
**Files:**
|
||||
- Modify: `.plans/012-security-hardening-config-and-integrity/2026-03-21-security-hardening-config-and-integrity-design.md` if implementation drifted
|
||||
- Modify: `.plans/012-security-hardening-config-and-integrity/2026-03-21-security-hardening-config-and-integrity-implementation-plan.md` if task wording drifted
|
||||
- Modify: `.architecture/security-issues.md` if final wording needs adjustment
|
||||
|
||||
**Step 1: Run formatting and full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
Expected: all commands succeed.
|
||||
|
||||
**Step 2: Re-read the security docs**
|
||||
|
||||
Confirm the final README and `.architecture/security-issues.md` text still matches the implementation.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .plans/012-security-hardening-config-and-integrity .architecture/security-issues.md README.md
|
||||
git commit -m "docs: record security hardening plan"
|
||||
```
|
||||
|
|
@ -5,3 +5,9 @@ Docuemnts of these types MUST live in `.plans/` within a sensibly named and inde
|
|||
## Audits
|
||||
IMPORTANT!!
|
||||
Audits are to live in `.audits` with a good name slug plus time & date.
|
||||
|
||||
## Architecture
|
||||
IMPORTANT TO CHECK BEFORE ANY COMMIT!!
|
||||
Architecture under `.architecture` must be maintained with each change.
|
||||
- Security issues stumbled upon or noticed during execution **already in code** must live in `security-issues.md`. Newly added issues during execution or planning should be raised to the user and/or dealt with, instead of growing the list.
|
||||
- An overview of the workspace, should live in `overview.md`.
|
||||
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -36,6 +36,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"base64",
|
||||
"fs2",
|
||||
"md5",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
|
@ -981,6 +982,12 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ crossterm = "0.28.1"
|
|||
fs2 = "0.4.3"
|
||||
indicatif = "0.17.11"
|
||||
libc = "0.2.171"
|
||||
md5 = "0.7.0"
|
||||
ratatui = "0.29.0"
|
||||
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
quick-xml = { version = "0.37.5", features = ["serialize"] }
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -49,6 +49,20 @@ By default `aim` auto-detects whether to use user or system scope. Override that
|
|||
- `--user`
|
||||
- `--system`
|
||||
|
||||
## Config
|
||||
|
||||
Runtime config is loaded from `~/.config/aim/config.toml` or `$XDG_CONFIG_HOME/aim/config.toml`.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
allow_http = true
|
||||
```
|
||||
|
||||
- `allow_http = false` is the default
|
||||
- `allow_http` only permits user-supplied `http://` inputs such as direct URL installs or updates from previously installed direct HTTP origins
|
||||
- provider-resolved downloads such as AppImageHub artifacts remain HTTPS-only even when `allow_http = true`
|
||||
|
||||
## Current Flow Shape
|
||||
|
||||
- `aim <QUERY>` installs direct provider matches when available, otherwise falls back to search results, shows live progress on stderr, prints an `Installation Summary` on stdout for installs, and renders an `Installation Review` when tracking needs confirmation
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize)]
|
||||
pub struct CliConfig {
|
||||
#[serde(default)]
|
||||
pub allow_http: bool,
|
||||
#[serde(default)]
|
||||
pub search: SearchConfig,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ use std::env;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aim_core::app::add::{
|
||||
AddPlan, InstalledApp, build_add_plan_with_reporter, install_app_with_reporter,
|
||||
resolve_requested_scope,
|
||||
AddPlan, AddSecurityPolicy, InstalledApp, build_add_plan_with_reporter_and_policy,
|
||||
install_app_with_reporter, resolve_requested_scope,
|
||||
};
|
||||
use aim_core::app::list::{ListRow, build_list_rows};
|
||||
use aim_core::app::progress::{
|
||||
|
|
@ -17,7 +17,7 @@ use aim_core::app::progress::{
|
|||
use aim_core::app::remove::{RemovalResult, remove_registered_app_with_reporter};
|
||||
use aim_core::app::search::build_search_results;
|
||||
use aim_core::app::show::{build_installed_show_results, build_show_result};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates_with_reporter_and_policy};
|
||||
use aim_core::domain::app::AppRecord;
|
||||
use aim_core::domain::search::{SearchQuery, SearchResults};
|
||||
use aim_core::domain::show::{InstalledShow, ShowResult};
|
||||
|
|
@ -38,6 +38,14 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
|||
pub fn dispatch_with_reporter(
|
||||
cli: Cli,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<DispatchResult, DispatchError> {
|
||||
dispatch_with_reporter_and_config(cli, &crate::config::CliConfig::default(), reporter)
|
||||
}
|
||||
|
||||
pub fn dispatch_with_reporter_and_config(
|
||||
cli: Cli,
|
||||
config: &crate::config::CliConfig,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<DispatchResult, DispatchError> {
|
||||
let registry_path = registry_path();
|
||||
let install_home = install_home(®istry_path);
|
||||
|
|
@ -86,7 +94,14 @@ pub fn dispatch_with_reporter(
|
|||
None => Ok(DispatchResult::ShowAll(build_installed_show_results(&apps))),
|
||||
},
|
||||
cli::args::Command::Update => {
|
||||
let updates = execute_updates_with_reporter(&apps, &install_home, reporter)?;
|
||||
let updates = execute_updates_with_reporter_and_policy(
|
||||
&apps,
|
||||
&install_home,
|
||||
reporter,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: config.allow_http,
|
||||
},
|
||||
)?;
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SaveRegistry,
|
||||
message: "saving registry".to_owned(),
|
||||
|
|
@ -109,7 +124,14 @@ pub fn dispatch_with_reporter(
|
|||
if let Some(query) = cli.query {
|
||||
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
|
||||
let transport = aim_core::source::github::default_transport();
|
||||
let plan_result = build_add_plan_with_reporter(&query, transport.as_ref(), reporter);
|
||||
let plan_result = build_add_plan_with_reporter_and_policy(
|
||||
&query,
|
||||
transport.as_ref(),
|
||||
reporter,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: config.allow_http,
|
||||
},
|
||||
);
|
||||
let mut plan = match plan_result {
|
||||
Ok(plan) => plan,
|
||||
Err(
|
||||
|
|
@ -209,6 +231,10 @@ impl std::fmt::Display for DispatchError {
|
|||
aim_core::app::add::BuildAddPlanError::Query(
|
||||
aim_core::app::query::ResolveQueryError::Unsupported,
|
||||
) => write!(f, "unsupported source query"),
|
||||
aim_core::app::add::BuildAddPlanError::InsecureHttpSource { .. } => write!(
|
||||
f,
|
||||
"insecure HTTP sources are disabled; set allow_http = true to permit them"
|
||||
),
|
||||
aim_core::app::add::BuildAddPlanError::NoInstallableArtifact { source } => write!(
|
||||
f,
|
||||
"no installable artifact found for {} {}",
|
||||
|
|
@ -233,7 +259,7 @@ impl std::fmt::Display for DispatchError {
|
|||
write!(f, "no installable candidates found")
|
||||
}
|
||||
},
|
||||
Self::AddInstall(error) => write!(f, "install failed: {error:?}"),
|
||||
Self::AddInstall(error) => write!(f, "install failed: {}", render_install_error(error)),
|
||||
Self::Prompt(error) => write!(f, "prompt failed: {error:?}"),
|
||||
Self::RemovePlan(error) => write!(f, "remove failed: {error:?}"),
|
||||
Self::Registry(error) => write!(f, "registry failed: {error:?}"),
|
||||
|
|
@ -250,6 +276,10 @@ impl std::fmt::Display for DispatchError {
|
|||
aim_core::domain::show::ShowResultError::UnsupportedQuery => {
|
||||
write!(f, "unsupported source query")
|
||||
}
|
||||
aim_core::domain::show::ShowResultError::InsecureHttpSource => write!(
|
||||
f,
|
||||
"insecure HTTP sources are disabled; set allow_http = true to permit them"
|
||||
),
|
||||
aim_core::domain::show::ShowResultError::NoInstallableArtifact { source } => {
|
||||
write!(
|
||||
f,
|
||||
|
|
@ -307,6 +337,17 @@ impl std::fmt::Display for DispatchError {
|
|||
}
|
||||
}
|
||||
|
||||
fn render_install_error(error: &aim_core::app::add::InstallAppError) -> String {
|
||||
match error {
|
||||
aim_core::app::add::InstallAppError::Materialize(error) => format!("{error:?}"),
|
||||
aim_core::app::add::InstallAppError::Policy(error) => error.clone(),
|
||||
aim_core::app::add::InstallAppError::Download(error) => error.to_string(),
|
||||
aim_core::app::add::InstallAppError::DownloadIo(error) => error.to_string(),
|
||||
aim_core::app::add::InstallAppError::HostProbe(error) => error.to_string(),
|
||||
aim_core::app::add::InstallAppError::Install(error) => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
|
||||
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
|
||||
Self::AddPlan(value)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ fn main() {
|
|||
|
||||
let cli = aim_cli::parse();
|
||||
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
|
||||
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
|
||||
match aim_cli::dispatch_with_reporter_and_config(cli, &config, &mut reporter) {
|
||||
Ok(result) => {
|
||||
let output = aim_cli::render_with_config(&result, &config);
|
||||
if !output.is_empty() {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ fn missing_config_file_returns_defaults() {
|
|||
|
||||
assert_eq!(config, CliConfig::default());
|
||||
assert_eq!(config.search, SearchConfig::default());
|
||||
assert!(!config.allow_http);
|
||||
assert!(config.search.bottom_to_top);
|
||||
assert!(!config.search.skip_confirmation);
|
||||
assert_eq!(config.theme.accent, "#b388ff");
|
||||
|
|
@ -23,7 +24,7 @@ fn search_section_overrides_defaults() {
|
|||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
|
||||
"allow_http = true\n\n[search]\nbottom_to_top = false\nskip_confirmation = true\n\n[theme]\naccent = \"#9f6bff\"\naccent_secondary = \"#efe7ff\"\ndim = \"#6b6480\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ fn search_section_overrides_defaults() {
|
|||
assert_eq!(
|
||||
config,
|
||||
CliConfig {
|
||||
allow_http: true,
|
||||
search: SearchConfig {
|
||||
bottom_to_top: false,
|
||||
skip_confirmation: true,
|
||||
|
|
|
|||
|
|
@ -339,6 +339,74 @@ fn cli_add_installs_sourceforge_latest_download_with_truthful_origin() {
|
|||
assert!(contents.contains("canonical_locator = \"team-app\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_insecure_http_direct_urls_by_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("http://example.com/team-app.AppImage")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("insecure HTTP sources are disabled"));
|
||||
|
||||
assert!(!registry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_allows_insecure_http_direct_urls_when_config_enables_it() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(&config_path, "allow_http = true\n").unwrap();
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("http://example.com/team-app.AppImage")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env("AIM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Installed"))
|
||||
.stdout(contains(
|
||||
"Source: direct-url http://example.com/team-app.AppImage",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_insecure_appimagehub_download_urls_even_when_http_is_allowed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let config_path = dir.path().join("config.toml");
|
||||
std::fs::write(&config_path, "allow_http = true\n").unwrap();
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2338455")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env("AIM_CONFIG_PATH", &config_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("AIM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP", "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("insecure appimagehub download url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_rejects_appimagehub_install_when_md5_does_not_match() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("appimagehub/2338455")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.env(FIXTURE_MODE_ENV, "1")
|
||||
.env("AIM_APPIMAGEHUB_FIXTURE_BAD_MD5", "1")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains("weak provider checksum did not match"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_add_installs_sourceforge_release_folder_with_truthful_origin() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ fn install_summary_omits_completed_steps_recap() {
|
|||
version: "0.25.0".to_owned(),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "heuristic-match".to_owned(),
|
||||
},
|
||||
artifact_size_bytes: 173_015_040,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||
[dependencies]
|
||||
base64.workspace = true
|
||||
fs2.workspace = true
|
||||
md5.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::adapters::traits::{
|
|||
use crate::app::query::resolve_query;
|
||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
||||
use crate::source::appimagehub::{
|
||||
AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
|
||||
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
|
||||
};
|
||||
|
||||
pub struct AppImageHubAdapter;
|
||||
|
|
@ -20,7 +20,7 @@ impl AppImageHubAdapter {
|
|||
}
|
||||
|
||||
let resolved = resolve_appimagehub_item_with(source, transport)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?;
|
||||
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?;
|
||||
|
||||
match resolved {
|
||||
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
|
|
@ -64,7 +64,7 @@ impl SourceAdapter for AppImageHubAdapter {
|
|||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
match resolve_appimagehub_item(source)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
|
||||
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?
|
||||
{
|
||||
Some(item) => Ok(AdapterResolution {
|
||||
source: item.source,
|
||||
|
|
@ -87,3 +87,19 @@ impl SourceAdapter for AppImageHubAdapter {
|
|||
self.resolve_source_with(source, transport.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
fn render_appimagehub_error(error: &AppImageHubError) -> String {
|
||||
match error {
|
||||
AppImageHubError::FixtureItemMissing(id) => {
|
||||
format!("missing appimagehub fixture item {id}")
|
||||
}
|
||||
AppImageHubError::InsecureDownloadUrl(url) => {
|
||||
format!("insecure appimagehub download url: {url}")
|
||||
}
|
||||
AppImageHubError::Parse(error) => error.to_string(),
|
||||
AppImageHubError::Transport(error) => error.to_string(),
|
||||
AppImageHubError::UnsupportedSource(locator) => {
|
||||
format!("unsupported appimagehub source: {locator}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,20 @@ use crate::update::ranking::{rank_channels, select_artifact, to_preference};
|
|||
|
||||
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct AddSecurityPolicy {
|
||||
pub allow_http_user_sources: bool,
|
||||
}
|
||||
|
||||
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
|
||||
let transport = crate::source::github::default_transport();
|
||||
let mut reporter = NoopReporter;
|
||||
build_add_plan_with_reporter(query, transport.as_ref(), &mut reporter)
|
||||
build_add_plan_with_reporter_and_policy(
|
||||
query,
|
||||
transport.as_ref(),
|
||||
&mut reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
||||
|
|
@ -45,19 +55,39 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
|||
transport: &T,
|
||||
) -> Result<AddPlan, BuildAddPlanError> {
|
||||
let mut reporter = NoopReporter;
|
||||
build_add_plan_with_reporter(query, transport, &mut reporter)
|
||||
build_add_plan_with_reporter_and_policy(
|
||||
query,
|
||||
transport,
|
||||
&mut reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
||||
query: &str,
|
||||
transport: &T,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<AddPlan, BuildAddPlanError> {
|
||||
build_add_plan_with_reporter_and_policy(
|
||||
query,
|
||||
transport,
|
||||
reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
|
||||
query: &str,
|
||||
transport: &T,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
policy: AddSecurityPolicy,
|
||||
) -> Result<AddPlan, BuildAddPlanError> {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
message: "resolving source".to_owned(),
|
||||
});
|
||||
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
|
||||
validate_source_transport_policy(&source, policy)?;
|
||||
|
||||
let mut interactions = Vec::new();
|
||||
let mut parsed_metadata = Vec::new();
|
||||
|
|
@ -154,6 +184,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
|
||||
|
|
@ -197,6 +228,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolved_item.version.clone(),
|
||||
arch: resolved_item.download.arch.clone(),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: resolved_item.download.md5sum.clone(),
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -230,6 +262,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "exact-input".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -270,6 +303,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -300,6 +334,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: "unresolved".to_owned(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "heuristic-match".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -464,6 +499,7 @@ pub fn install_app_with_reporter(
|
|||
staged_payload_path: &staged_payload_path,
|
||||
final_payload_path: &payload_path,
|
||||
trusted_checksum: plan.selected_artifact.trusted_checksum.as_deref(),
|
||||
weak_checksum_md5: plan.selected_artifact.weak_checksum_md5.as_deref(),
|
||||
desktop: desktop_owned.as_ref().map(|(path, contents)| {
|
||||
crate::integration::install::DesktopIntegrationRequest {
|
||||
desktop_entry_path: path.as_path(),
|
||||
|
|
@ -548,6 +584,9 @@ pub struct InstalledApp {
|
|||
#[derive(Debug)]
|
||||
pub enum BuildAddPlanError {
|
||||
Query(ResolveQueryError),
|
||||
InsecureHttpSource {
|
||||
locator: String,
|
||||
},
|
||||
Adapter(&'static str, crate::adapters::traits::AdapterError),
|
||||
GitHubDiscovery(GitHubDiscoveryError),
|
||||
NoInstallableArtifact {
|
||||
|
|
@ -571,6 +610,19 @@ pub enum InstallAppError {
|
|||
Install(crate::integration::install::PayloadInstallError),
|
||||
}
|
||||
|
||||
fn validate_source_transport_policy(
|
||||
source: &crate::domain::source::SourceRef,
|
||||
policy: AddSecurityPolicy,
|
||||
) -> Result<(), BuildAddPlanError> {
|
||||
if source.locator.starts_with("http://") && !policy.allow_http_user_sources {
|
||||
return Err(BuildAddPlanError::InsecureHttpSource {
|
||||
locator: source.locator.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_artifact_to_staged_path_with_reporter(
|
||||
url: &str,
|
||||
staged_payload_path: &Path,
|
||||
|
|
@ -685,12 +737,32 @@ fn is_retryable_download_error(error: &InstallAppError) -> bool {
|
|||
}
|
||||
|
||||
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {
|
||||
let display_name = sanitize_desktop_entry_name(display_name);
|
||||
format!(
|
||||
"[Desktop Entry]\nName={display_name}\nExec={}\nType=Application\nCategories=Utility;\n",
|
||||
exec_path.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_desktop_entry_name(display_name: &str) -> String {
|
||||
let sanitized = display_name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if matches!(ch, '\n' | '\r') || ch.is_control() {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let sanitized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if sanitized.is_empty() {
|
||||
"app".to_owned()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_target_path(install_home: &Path, target: &Path) -> PathBuf {
|
||||
if target.is_absolute() {
|
||||
target.to_path_buf()
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ pub fn resolve_identity(
|
|||
fallback: IdentityFallback,
|
||||
) -> Result<AppIdentity, ResolveIdentityError> {
|
||||
if let Some(explicit_id) = explicit_id.filter(|value| !value.trim().is_empty()) {
|
||||
let stable_id = normalize_identifier(explicit_id);
|
||||
let stable_id = normalize_identifier(explicit_id)?;
|
||||
let display_name = explicit_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| explicit_id.to_owned());
|
||||
.map(sanitize_display_name)
|
||||
.unwrap_or_else(|| sanitize_display_name(explicit_id));
|
||||
|
||||
return Ok(AppIdentity {
|
||||
stable_id,
|
||||
|
|
@ -29,8 +29,8 @@ pub fn resolve_identity(
|
|||
|
||||
if let Some(explicit_name) = explicit_name.filter(|value| !value.trim().is_empty()) {
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_identifier(explicit_name),
|
||||
display_name: explicit_name.to_owned(),
|
||||
stable_id: normalize_identifier(explicit_name)?,
|
||||
display_name: sanitize_display_name(explicit_name),
|
||||
confidence: IdentityConfidence::NeedsConfirmation,
|
||||
});
|
||||
}
|
||||
|
|
@ -41,8 +41,8 @@ pub fn resolve_identity(
|
|||
{
|
||||
let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned();
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_identifier(&repo),
|
||||
display_name,
|
||||
stable_id: normalize_identifier(&repo)?,
|
||||
display_name: sanitize_display_name(&display_name),
|
||||
confidence: IdentityConfidence::Confident,
|
||||
});
|
||||
}
|
||||
|
|
@ -51,8 +51,8 @@ pub fn resolve_identity(
|
|||
&& fallback == IdentityFallback::AllowRawUrl
|
||||
{
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_url_identifier(source_url),
|
||||
display_name: source_url.to_owned(),
|
||||
stable_id: normalize_url_identifier(source_url)?,
|
||||
display_name: sanitize_display_name(source_url),
|
||||
confidence: IdentityConfidence::RawUrlFallback,
|
||||
});
|
||||
}
|
||||
|
|
@ -63,10 +63,11 @@ pub fn resolve_identity(
|
|||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum ResolveIdentityError {
|
||||
Unresolved,
|
||||
InvalidStableId,
|
||||
}
|
||||
|
||||
fn normalize_identifier(value: &str) -> String {
|
||||
value
|
||||
fn normalize_identifier(value: &str) -> Result<String, ResolveIdentityError> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
|
|
@ -76,15 +77,41 @@ fn normalize_identifier(value: &str) -> String {
|
|||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_owned()
|
||||
.to_owned();
|
||||
|
||||
if normalized.is_empty() || normalized.contains("..") {
|
||||
return Err(ResolveIdentityError::InvalidStableId);
|
||||
}
|
||||
|
||||
fn normalize_url_identifier(url: &str) -> String {
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_url_identifier(url: &str) -> Result<String, ResolveIdentityError> {
|
||||
let trimmed = url
|
||||
.trim()
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("file://");
|
||||
|
||||
format!("url-{}", normalize_identifier(trimmed))
|
||||
Ok(format!("url-{}", normalize_identifier(trimmed)?))
|
||||
}
|
||||
|
||||
fn sanitize_display_name(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if matches!(ch, '\n' | '\r') || ch.is_control() {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let sanitized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"app".to_owned()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ impl From<BuildAddPlanError> for ShowResultError {
|
|||
fn from(value: BuildAddPlanError) -> Self {
|
||||
match value {
|
||||
BuildAddPlanError::Query(_) => Self::UnsupportedQuery,
|
||||
BuildAddPlanError::InsecureHttpSource { .. } => Self::InsecureHttpSource,
|
||||
BuildAddPlanError::NoInstallableArtifact { source } => Self::NoInstallableArtifact {
|
||||
source: project_source_summary(&source),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::add::{build_add_plan, install_app_with_reporter};
|
||||
use crate::app::add::{
|
||||
AddSecurityPolicy, build_add_plan_with_reporter_and_policy, install_app_with_reporter,
|
||||
};
|
||||
use crate::app::progress::{
|
||||
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
|
||||
};
|
||||
|
|
@ -23,13 +25,32 @@ pub fn execute_updates(
|
|||
install_home: &Path,
|
||||
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||
let mut reporter = NoopReporter;
|
||||
execute_updates_with_reporter(apps, install_home, &mut reporter)
|
||||
execute_updates_with_reporter_and_policy(
|
||||
apps,
|
||||
install_home,
|
||||
&mut reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_updates_with_reporter(
|
||||
apps: &[AppRecord],
|
||||
install_home: &Path,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||
execute_updates_with_reporter_and_policy(
|
||||
apps,
|
||||
install_home,
|
||||
reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execute_updates_with_reporter_and_policy(
|
||||
apps: &[AppRecord],
|
||||
install_home: &Path,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
policy: AddSecurityPolicy,
|
||||
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||
reporter.report(&OperationEvent::Started {
|
||||
kind: OperationKind::UpdateBatch,
|
||||
|
|
@ -43,7 +64,7 @@ pub fn execute_updates_with_reporter(
|
|||
kind: OperationKind::UpdateItem,
|
||||
label: app.stable_id.clone(),
|
||||
});
|
||||
match execute_update(app, install_home, reporter) {
|
||||
match execute_update(app, install_home, reporter, policy) {
|
||||
Ok(updated) => {
|
||||
let warnings = updated
|
||||
.warnings
|
||||
|
|
@ -166,6 +187,7 @@ fn execute_update(
|
|||
app: &AppRecord,
|
||||
install_home: &Path,
|
||||
reporter: &mut impl ProgressReporter,
|
||||
policy: AddSecurityPolicy,
|
||||
) -> Result<crate::app::add::InstalledApp, String> {
|
||||
reporter.report(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
|
|
@ -184,7 +206,10 @@ fn execute_update(
|
|||
.as_ref()
|
||||
.map(|install| install.scope)
|
||||
.unwrap_or(InstallScope::User);
|
||||
let plan = build_add_plan(&query).map_err(|error| {
|
||||
let transport = crate::source::github::default_transport();
|
||||
let plan =
|
||||
build_add_plan_with_reporter_and_policy(&query, transport.as_ref(), reporter, policy)
|
||||
.map_err(|error| {
|
||||
let reason = format!("failed to build update plan: {error:?}");
|
||||
reporter.report(&OperationEvent::Failed {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub enum ShowResultError {
|
|||
matches: Vec<String>,
|
||||
},
|
||||
UnsupportedQuery,
|
||||
InsecureHttpSource,
|
||||
NoInstallableArtifact {
|
||||
source: SourceSummary,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ pub struct ArtifactCandidate {
|
|||
pub version: String,
|
||||
pub arch: Option<String>,
|
||||
pub trusted_checksum: Option<String>,
|
||||
pub weak_checksum_md5: Option<String>,
|
||||
pub selection_reason: String,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub enum PayloadInstallError {
|
|||
InvalidArtifact,
|
||||
ChecksumMismatch,
|
||||
InvalidTrustedChecksum,
|
||||
InvalidWeakChecksum,
|
||||
WeakChecksumMismatch,
|
||||
Io(io::Error),
|
||||
DesktopIntegration(io::Error),
|
||||
}
|
||||
|
|
@ -46,6 +48,13 @@ impl fmt::Display for PayloadInstallError {
|
|||
Self::InvalidArtifact => write!(f, "artifact is not a valid AppImage"),
|
||||
Self::ChecksumMismatch => write!(f, "artifact checksum did not match trusted metadata"),
|
||||
Self::InvalidTrustedChecksum => write!(f, "trusted checksum metadata is malformed"),
|
||||
Self::InvalidWeakChecksum => write!(f, "weak provider checksum metadata is malformed"),
|
||||
Self::WeakChecksumMismatch => {
|
||||
write!(
|
||||
f,
|
||||
"weak provider checksum did not match downloaded artifact"
|
||||
)
|
||||
}
|
||||
Self::Io(error) => write!(f, "payload installation failed: {error}"),
|
||||
Self::DesktopIntegration(error) => {
|
||||
write!(f, "desktop integration failed: {error}")
|
||||
|
|
@ -74,6 +83,7 @@ pub struct InstallRequest<'a> {
|
|||
pub staged_payload_path: &'a Path,
|
||||
pub final_payload_path: &'a Path,
|
||||
pub trusted_checksum: Option<&'a str>,
|
||||
pub weak_checksum_md5: Option<&'a str>,
|
||||
pub desktop: Option<DesktopIntegrationRequest<'a>>,
|
||||
pub helpers: DesktopHelpers,
|
||||
}
|
||||
|
|
@ -124,6 +134,7 @@ pub fn execute_install(
|
|||
request: &InstallRequest<'_>,
|
||||
) -> Result<InstallOutcome, PayloadInstallError> {
|
||||
verify_trusted_checksum(request.staged_payload_path, request.trusted_checksum)?;
|
||||
verify_weak_checksum_md5(request.staged_payload_path, request.weak_checksum_md5)?;
|
||||
let payload =
|
||||
stage_and_commit_payload(request.staged_payload_path, request.final_payload_path)?;
|
||||
|
||||
|
|
@ -197,3 +208,30 @@ fn verify_trusted_checksum(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_weak_checksum_md5(
|
||||
staged_payload_path: &Path,
|
||||
weak_checksum_md5: Option<&str>,
|
||||
) -> Result<(), PayloadInstallError> {
|
||||
let Some(weak_checksum_md5) = weak_checksum_md5.map(str::trim) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if weak_checksum_md5.len() != 32
|
||||
|| !weak_checksum_md5
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
let _ = fs::remove_file(staged_payload_path);
|
||||
return Err(PayloadInstallError::InvalidWeakChecksum);
|
||||
}
|
||||
|
||||
let payload = fs::read(staged_payload_path)?;
|
||||
let actual_checksum = format!("{:x}", md5::compute(payload));
|
||||
if actual_checksum != weak_checksum_md5.to_ascii_lowercase() {
|
||||
let _ = fs::remove_file(staged_payload_path);
|
||||
return Err(PayloadInstallError::WeakChecksumMismatch);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -14,7 +15,11 @@ pub fn refresh_integration(
|
|||
helpers.update_desktop_database_path.as_ref(),
|
||||
desktop_entry_path.and_then(Path::parent),
|
||||
) {
|
||||
if let Err(error) = Command::new(helper).arg(path).status() {
|
||||
audit_helper(helper, &[path]);
|
||||
if let Err(error) = Command::new(helper).arg(path).status().inspect(|status| {
|
||||
audit_helper_status(helper, status.code());
|
||||
}) {
|
||||
audit_helper_failure(helper, &error.to_string());
|
||||
warnings.push(format!("update-desktop-database failed: {error}"));
|
||||
}
|
||||
} else if !helpers.update_desktop_database {
|
||||
|
|
@ -27,7 +32,16 @@ pub fn refresh_integration(
|
|||
helpers.gtk_update_icon_cache_path.as_ref(),
|
||||
icon_path.map(icon_theme_root),
|
||||
) {
|
||||
if let Err(error) = Command::new(helper).args(["-f", "-t"]).arg(path).status() {
|
||||
audit_helper(helper, &[Path::new("-f"), Path::new("-t"), path.as_path()]);
|
||||
if let Err(error) = Command::new(helper)
|
||||
.args(["-f", "-t"])
|
||||
.arg(path)
|
||||
.status()
|
||||
.inspect(|status| {
|
||||
audit_helper_status(helper, status.code());
|
||||
})
|
||||
{
|
||||
audit_helper_failure(helper, &error.to_string());
|
||||
warnings.push(format!("gtk-update-icon-cache failed: {error}"));
|
||||
}
|
||||
} else if !helpers.gtk_update_icon_cache {
|
||||
|
|
@ -46,3 +60,43 @@ fn icon_theme_root(icon_path: &Path) -> PathBuf {
|
|||
|
||||
icon_path.parent().unwrap_or(icon_path).to_path_buf()
|
||||
}
|
||||
|
||||
fn audit_helper(helper: &Path, args: &[&Path]) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let rendered_args = args
|
||||
.iter()
|
||||
.map(|arg| arg.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!(
|
||||
"[aim] helper exec: {}{}{}",
|
||||
helper.display(),
|
||||
if rendered_args.is_empty() { "" } else { " " },
|
||||
rendered_args
|
||||
);
|
||||
}
|
||||
|
||||
fn audit_helper_status(helper: &Path, code: Option<i32>) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
match code {
|
||||
Some(code) => eprintln!("[aim] helper exit: {} code={code}", helper.display()),
|
||||
None => eprintln!(
|
||||
"[aim] helper exit: {} terminated by signal",
|
||||
helper.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_helper_failure(helper: &Path, error: &str) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("[aim] helper failure: {} error={error}", helper.display());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
validate_download_url(&download.url)?;
|
||||
|
||||
Ok(Some(ResolvedAppImageHubItem {
|
||||
source: source.clone(),
|
||||
title: item.name.clone(),
|
||||
|
|
@ -190,6 +192,7 @@ impl AppImageHubTransport for FixtureAppImageHubTransport {
|
|||
#[derive(Debug)]
|
||||
pub enum AppImageHubError {
|
||||
FixtureItemMissing(String),
|
||||
InsecureDownloadUrl(String),
|
||||
Parse(quick_xml::DeError),
|
||||
Transport(reqwest::Error),
|
||||
UnsupportedSource(String),
|
||||
|
|
@ -300,6 +303,14 @@ fn content_to_item(content: OcsContent) -> AppImageHubItem {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_download_url(url: &str) -> Result<(), AppImageHubError> {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(AppImageHubError::InsecureDownloadUrl(url.to_owned()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_downloads(content: &OcsContent) -> Vec<AppImageHubDownload> {
|
||||
let mut downloads = Vec::new();
|
||||
|
||||
|
|
@ -413,6 +424,12 @@ fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> S
|
|||
}
|
||||
|
||||
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
|
||||
let insecure_http = env::var("AIM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some("1");
|
||||
let bad_md5 = env::var("AIM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
|
||||
|
||||
match id {
|
||||
"2338455" => Some(AppImageHubItem {
|
||||
id: "2338455".to_owned(),
|
||||
|
|
@ -427,12 +444,20 @@ fn fixture_item(id: &str) -> Option<AppImageHubItem> {
|
|||
"release-stable".to_owned(),
|
||||
],
|
||||
downloads: vec![AppImageHubDownload {
|
||||
url: "https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
|
||||
.to_owned(),
|
||||
url: if insecure_http {
|
||||
"http://files06.pling.com/api/files/download/firefox-x86-64.AppImage".to_owned()
|
||||
} else {
|
||||
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
|
||||
.to_owned()
|
||||
},
|
||||
name: "firefox-x86-64.AppImage".to_owned(),
|
||||
package_type: Some("appimage".to_owned()),
|
||||
arch: Some("x86-64".to_owned()),
|
||||
md5sum: Some("1befdc026535be03a6001f33b11ef91d".to_owned()),
|
||||
md5sum: Some(if bad_md5 {
|
||||
"00000000000000000000000000000000".to_owned()
|
||||
} else {
|
||||
"2a685cf45213d5a2a243273fa68dafa6".to_owned()
|
||||
}),
|
||||
version: None,
|
||||
}],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ pub fn select_artifact(
|
|||
.unwrap_or_else(|| "latest".to_owned()),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: hints.and_then(|value| value.checksum.clone()),
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: selection_reason.to_owned(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ fn install_succeeds_with_valid_trusted_checksum() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
@ -39,6 +40,7 @@ fn install_succeeds_without_trusted_checksum() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
@ -57,6 +59,7 @@ fn install_fails_before_commit_when_trusted_checksum_mismatches() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
@ -77,6 +80,7 @@ fn malformed_trusted_checksum_fails_before_commit() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some("not-base64"),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
@ -87,6 +91,46 @@ fn malformed_trusted_checksum_fails_before_commit() {
|
|||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_with_valid_weak_md5_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: Some("474a0eb1bbe0a6e62715ce83922a5bf7"),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_fails_before_commit_when_weak_md5_checksum_mismatches() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: Some("00000000000000000000000000000000"),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::WeakChecksumMismatch));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let staged_path = root.join("staging/bat.download");
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ fn install_commits_from_staged_payload_path() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,3 +29,19 @@ fn explicit_id_is_treated_as_confident() {
|
|||
assert_eq!(identity.display_name, "Bat");
|
||||
assert_eq!(identity.confidence, IdentityConfidence::Confident);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_containing_dot_dot_are_rejected() {
|
||||
let error = resolve_identity(
|
||||
Some("Bat"),
|
||||
Some(".."),
|
||||
Some("https://example.com/app.AppImage"),
|
||||
IdentityFallback::AllowRawUrl,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
aim_core::app::identity::ResolveIdentityError::InvalidStableId
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fn integration_failure_removes_new_payload_and_generated_files() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_entry_path,
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -83,6 +84,7 @@ fn install_executes_refresh_helpers_when_available() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -124,6 +126,7 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -240,6 +243,46 @@ fn install_app_reports_operation_stages_in_order() {
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_app_sanitizes_desktop_entry_display_names() {
|
||||
let root = tempdir().unwrap();
|
||||
let mut reporter = Vec::new();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut capture = |event: &OperationEvent| reporter.push(event.clone());
|
||||
let mut plan =
|
||||
build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut capture).unwrap();
|
||||
plan.display_name_hint = Some("Bat\nExec=evil".to_owned());
|
||||
|
||||
let installed = install_app_with_reporter(
|
||||
"sharkdp/bat",
|
||||
&plan,
|
||||
root.path(),
|
||||
InstallScope::User,
|
||||
&mut capture,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let desktop_path = installed
|
||||
.install_outcome
|
||||
.desktop_entry_path
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(desktop_path).unwrap();
|
||||
|
||||
assert!(contents.contains("Name=Bat Exec=evil"));
|
||||
assert_eq!(
|
||||
contents
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Exec="))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_source_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
|
||||
use aim_core::app::add::AddSecurityPolicy;
|
||||
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
|
||||
use aim_core::app::update::{
|
||||
build_update_plan, execute_updates, execute_updates_with_reporter,
|
||||
execute_updates_with_reporter_and_policy,
|
||||
};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||
|
|
@ -311,6 +315,103 @@ fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_http_updates_are_rejected_by_default() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
|
||||
|
||||
assert_eq!(result.updated_count(), 0);
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
assert!(matches!(
|
||||
&result.items[0].status,
|
||||
aim_core::domain::update::UpdateExecutionStatus::Failed { reason }
|
||||
if reason.contains("InsecureHttpSource")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_http_updates_can_be_allowed_by_policy() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates_with_reporter_and_policy(
|
||||
std::slice::from_ref(&previous),
|
||||
install_home.path(),
|
||||
&mut NoopReporter,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_count(), 1);
|
||||
assert_eq!(result.failed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs() {
|
||||
let install_home = tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue