Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,26 @@ cargo-oxidate Cargo.lock --min-age-days 14 --max-age-days 730
| `--include-prerelease` | Consider prerelease versions as suggestion candidates (requires `--suggest-fix`); ordinary SemVer requirements (e.g. `^1.2`) still generally don't match prereleases, so most will still be rejected |
| `--cache-path PATH` | Enable response caching at PATH (or set `CARGO_OXIDATE_CACHE_PATH`) |
| `--cache-max-age-hours N` | Max age for cached version listings (default: 24) |
| `--quiet` | Suppress start and per-package progress messages |
| `--verbose` | Show each package as it is checked |
| `--format text\|json` | Render the final result as text (default) or JSON |

At least one of `--min-age-days` or `--max-age-days` must be specified.

## CI output

The final report goes to standard output. Progress, warnings, and errors go to
standard error. `--quiet` hides start and progress messages, while `--verbose`
shows each package check. The flags cannot be combined.

`--format json` writes one newline-terminated schema version 1 document. Its
`status` is `passed`, `violations`, or `error`, matching exit codes 0, 1, and 2.
The command also writes a document when it cannot load the lockfile. Consumers
should ignore unknown fields because schema version 1 may add fields.

Registry failures remain errors. Cache failures produce warnings and do not
change the result.

## `--suggest-fix`

`--suggest-fix` prints a `cargo update --precise` command for the newest eligible downgrade of
Expand All @@ -52,14 +69,16 @@ treats every declared requirement, including optional and target-specific ones,

Suggestions are best effort. The tool does not run Cargo's resolver or build your project, so Cargo
can still reject a suggested command. Apply suggestions in order, then run the command again and
run your tests. If the tool cannot find an eligible downgrade, it reports the requirement that
blocks one when it knows that requirement.
run your tests. For each package without a suggestion, the command explains
which dependency blocks the downgrade or why it could not choose a version.

## Exit Codes

- `0` — No violations found
- `1` — Violations detected
- `2` — Runtime error
| Code | Meaning |
|------|---------|
| `0` | No dependency age violations |
| `1` | Dependency age violations found |
| `2` | Invalid input or incomplete required check |

## Caching

Expand Down
66 changes: 57 additions & 9 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::num::NonZeroU32;
use std::path::Path;
use std::time::Duration;

use crate::cache::ResponseCache;
use crate::cache::{CacheWarning, ResponseCache};

#[derive(Deserialize)]
struct CrateVersionResponse {
Expand Down Expand Up @@ -189,6 +189,7 @@ impl Default for RetryPolicy {
pub struct CratesIoClient<T: Transport = UreqTransport> {
transport: T,
cache: ResponseCache,
cache_warning: Option<CacheWarning>,
cache_max_age_hours: u64,
retry_policy: RetryPolicy,
/// Per-run memo of successfully fetched index records, keyed by crate
Expand Down Expand Up @@ -218,22 +219,29 @@ impl<T: Transport> CratesIoClient<T> {
cache_max_age_hours: u64,
retry_policy: RetryPolicy,
) -> Self {
let mut cache = ResponseCache::load(cache_path);
let cache_warning = cache.take_load_warning();

Self {
transport,
cache: ResponseCache::load(cache_path),
cache,
cache_warning,
cache_max_age_hours,
retry_policy,
fetched_index_records: HashMap::new(),
}
}

/// Consumes the client, saving the cache. Cache write failures are
/// reported to stderr rather than propagated, since a broken cache
/// directory shouldn't fail the whole run.
pub fn finish(self) {
if let Err(e) = self.cache.save() {
eprintln!("Warning: failed to save cache: {e}");
}
pub fn take_cache_warning(&mut self) -> Option<CacheWarning> {
self.cache_warning.take()
}

/// Saves the cache and returns any save error as a warning.
pub fn finish(self) -> Option<CacheWarning> {
self.cache
.save()
.err()
.map(|error| self.cache.save_warning(error))
}

/// Sleeps for the inter-request rate limit window. Called only from the
Expand Down Expand Up @@ -536,6 +544,46 @@ mod tests {
)
}

#[test]
fn finish_returns_cache_save_warning_without_failing_the_lookup() {
let dir = tempdir().unwrap();
let blocking_parent = dir.path().join("not-a-directory");
std::fs::write(&blocking_parent, "file").unwrap();
let cache_path = blocking_parent.join("responses.json");
let url = version_url("serde", "1.0.0");
let transport = FakeTransport::new();
transport.push(
&url,
ScriptedResponse::Http(200, version_body("2020-01-01T00:00:00Z")),
);
let mut client = CratesIoClient::with_transport(
transport,
Some(&cache_path),
24,
RetryPolicy {
retry_count: NonZeroU32::new(3).unwrap(),
retry_delay: Duration::ZERO,
pacing_delay: Duration::ZERO,
},
);

assert!(
client
.fetch_publish_date("serde", "1.0.0")
.unwrap()
.is_some()
);
let warning = client
.finish()
.expect("cache save failure should be reported");
assert_eq!(warning.path, cache_path);
assert!(
warning
.message
.contains("does not change the dependency check result")
);
}

#[test]
fn retries_429_then_succeeds() {
let url = version_url("serde", "1.0.0");
Expand Down
96 changes: 84 additions & 12 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,42 +33,66 @@ pub struct ResponseCache {
path: Option<PathBuf>,
data: CacheData,
dirty: bool,
load_warning: Option<CacheWarning>,
}

/// A cache failure that does not stop the dependency check.
pub struct CacheWarning {
pub path: PathBuf,
pub message: String,
}

impl ResponseCache {
pub fn load(path: Option<&Path>) -> Self {
let mut load_warning = None;
let path = path.map(|p| p.to_path_buf());

let data = if let Some(ref p) = path {
match std::fs::read_to_string(p) {
Ok(contents) => match serde_json::from_str::<CacheData>(&contents) {
Ok(data) if data.version == CACHE_VERSION => data,
Ok(data) => {
eprintln!(
"Warning: unsupported cache version {} at {}, starting fresh",
data.version,
p.display()
);
load_warning = Some(CacheWarning {
path: p.clone(),
message: format!(
"Cache version {} is unsupported. Checking without cached responses",
data.version
),
});
CacheData {
version: CACHE_VERSION,
..Default::default()
}
}
Err(e) => {
eprintln!(
"Warning: corrupted cache file at {}, starting fresh: {e}",
p.display()
);
load_warning = Some(CacheWarning {
path: p.clone(),
message: format!(
"Could not read cache: {e}. Checking without cached responses"
),
});
CacheData {
version: CACHE_VERSION,
..Default::default()
}
}
},
Err(_) => CacheData {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => CacheData {
version: CACHE_VERSION,
..Default::default()
},
Err(error) => {
load_warning = Some(CacheWarning {
path: p.clone(),
message: format!(
"Could not read cache: {error}. Checking without cached responses"
),
});
CacheData {
version: CACHE_VERSION,
..Default::default()
}
}
}
} else {
CacheData {
Expand All @@ -81,6 +105,23 @@ impl ResponseCache {
path,
data,
dirty: false,
load_warning,
}
}

pub fn take_load_warning(&mut self) -> Option<CacheWarning> {
self.load_warning.take()
}

pub fn save_warning(&self, error: anyhow::Error) -> CacheWarning {
CacheWarning {
path: self
.path
.clone()
.expect("cache save failures require a configured path"),
message: format!(
"Could not save cache: {error}. This does not change the dependency check result"
),
}
}

Expand Down Expand Up @@ -251,13 +292,22 @@ mod tests {
let path = dir.path().join("cache.json");
std::fs::write(&path, "{ not valid json").unwrap();

let cache = ResponseCache::load(Some(&path));
let mut cache = ResponseCache::load(Some(&path));
assert_eq!(cache.get_publish_date("anything", "0.0.1"), None);
assert!(
cache
.get_all_versions("anything", Duration::hours(1))
.is_none()
);
let warning = cache
.take_load_warning()
.expect("corrupt cache should produce a warning");
assert_eq!(warning.path, path);
assert!(
warning
.message
.contains("Checking without cached responses")
);
}

#[test]
Expand Down Expand Up @@ -290,8 +340,30 @@ mod tests {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");

let cache = ResponseCache::load(Some(&path));
let mut cache = ResponseCache::load(Some(&path));
assert_eq!(cache.get_publish_date("serde", "1.0.0"), None);
assert!(cache.take_load_warning().is_none());
}

#[test]
fn save_failure_has_a_nonfatal_warning() {
let dir = tempdir().unwrap();
let blocking_parent = dir.path().join("not-a-directory");
std::fs::write(&blocking_parent, "file").unwrap();
let path = blocking_parent.join("cache.json");
let mut cache = ResponseCache::load(Some(&path));
cache.set_publish_date("serde", "1.0.0", sample_date());

let error = cache
.save()
.expect_err("a file cannot be a cache directory");
let warning = cache.save_warning(error);
assert_eq!(warning.path, path);
assert!(
warning
.message
.contains("does not change the dependency check result")
);
}

#[test]
Expand Down
Loading
Loading