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
11 changes: 8 additions & 3 deletions crates/peryx/src/api/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2154,8 +2154,13 @@ fn openapi_endpoint() -> OperationBuilder {
.summary(Some("OpenAPI schema"))
.response(
"200",
ResponseBuilder::new()
.description("OpenAPI 3.1 schema")
.content("application/json", ContentBuilder::new().build()),
api_json_response(
"OpenAPI 3.1 schema",
json!({
"openapi": "3.1.0",
"info": {"title": "peryx", "version": env!("CARGO_PKG_VERSION")},
"paths": {}
}),
),
)
}
8 changes: 4 additions & 4 deletions crates/peryx/src/api/trash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ fn trash_record_example() -> serde_json::Value {
json!({
"ecosystem": "example",
"repository": "hosted",
"name": "example",
"reference": "example-1.0.bin",
"resource": "example",
"artifact": "example-1.0.bin",
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"reason": "compromised build",
"actor": "usr_550e8400e29b41d4a716446655440000",
Expand Down Expand Up @@ -159,9 +159,9 @@ fn inspect_trash() -> OperationBuilder {
"The repository route the artifact was deleted from",
json!("hosted"),
),
("name", true, "The ecosystem artifact name", json!("example")),
("resource", true, "The ecosystem resource name", json!("example")),
(
"reference",
"artifact",
false,
"The ecosystem artifact reference, if any",
json!("example-1.0.bin"),
Expand Down
55 changes: 32 additions & 23 deletions crates/peryx/src/operator/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,32 @@ impl BackupTarget {
Ok(())
}

/// A backup member: a file that must not already exist (`CREATE` with `EXCL`), is opened for
/// writing, is never reached through a symlink, and is never inherited by a child process.
///
/// Written as `union` rather than `|` because this is a fixed set, not a computation. Spelling
/// it as a bit operation invites `^`, which happens to produce the same value only while every
/// flag is a disjoint bit; a flag that later overlaps an existing one would make the two
/// diverge silently. `union` names the operation the set actually wants.
#[cfg(unix)]
const MEMBER_FLAGS: rustix::fs::OFlags = rustix::fs::OFlags::RDWR
.union(rustix::fs::OFlags::CREATE)
.union(rustix::fs::OFlags::EXCL)
.union(rustix::fs::OFlags::NOFOLLOW)
.union(rustix::fs::OFlags::CLOEXEC);

/// A directory on the way to a member: every component must itself be a directory, reached
/// without following a symlink, and not inherited by a child. Same reasoning as
/// [`Self::MEMBER_FLAGS`] for `union`.
#[cfg(unix)]
const COMPONENT_FLAGS: rustix::fs::OFlags = rustix::fs::OFlags::RDONLY
.union(rustix::fs::OFlags::DIRECTORY)
.union(rustix::fs::OFlags::NOFOLLOW)
.union(rustix::fs::OFlags::CLOEXEC);

#[cfg(unix)]
fn create_file(&self, path: &Path, access: Access) -> anyhow::Result<File> {
use rustix::fs::{Mode, OFlags};
use rustix::fs::Mode;

let parent = self.open_parent(path)?;
let name = path
Expand All @@ -321,13 +344,8 @@ impl BackupTarget {
Access::Shared => Mode::from_raw_mode(0o666),
};
Ok(File::from(
rustix::fs::openat(
&parent,
name,
OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
mode,
)
.context(format!("create backup member {}", path.display()))?,
rustix::fs::openat(&parent, name, Self::MEMBER_FLAGS, mode)
.context(format!("create backup member {}", path.display()))?,
))
}

Expand All @@ -348,7 +366,7 @@ impl BackupTarget {

#[cfg(unix)]
fn open_parent(&self, path: &Path) -> anyhow::Result<File> {
use rustix::fs::{Mode, OFlags};
use rustix::fs::Mode;

let mut parent = self.dir.try_clone()?;
let path = path.parent().context("backup members always carry a file name")?;
Expand All @@ -358,13 +376,8 @@ impl BackupTarget {
Err(error) => return Err(error).context(format!("create backup directory {}", path.display())),
}
parent = File::from(
rustix::fs::openat(
&parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.context(format!("open backup directory {}", path.display()))?,
rustix::fs::openat(&parent, name, Self::COMPONENT_FLAGS, Mode::empty())
.context(format!("open backup directory {}", path.display()))?,
);
}
Ok(parent)
Expand All @@ -380,15 +393,11 @@ fn staging_parent(path: &Path) -> anyhow::Result<&Path> {

#[cfg(unix)]
fn open_dir(path: &Path) -> anyhow::Result<File> {
use rustix::fs::{Mode, OFlags};
use rustix::fs::Mode;

Ok(File::from(
rustix::fs::open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.context(format!("open backup directory {}", path.display()))?,
rustix::fs::open(path, BackupTarget::COMPONENT_FLAGS, Mode::empty())
.context(format!("open backup directory {}", path.display()))?,
))
}

Expand Down
3 changes: 2 additions & 1 deletion crates/peryx/src/operator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ pub use verify::{backup_verify, backup_verify_with_plugins};
pub use writer::{claim_writer, claim_writer_with_plugins, promote_writer, promote_writer_with_plugins};

const BACKUP_FORMAT: u32 = 2;
const BUFFER_BYTES: usize = 1024 * 1024;
/// 1 MiB, the copy buffer for archive members and blobs.
const BUFFER_BYTES: usize = 1_048_576;
const BLOB_INDEX_HEADER: &str = "sha256\tsize_bytes\tpath";
/// Prefix every backup staging sibling carries, so an attempt killed before it could clean up leaves a
/// name an operator recognizes and can delete. A retry reserves a fresh randomized name, so one left
Expand Down
161 changes: 161 additions & 0 deletions crates/peryx/tests/unit/tests/api/shared_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,164 @@ fn test_protected_reads_do_not_require_the_write_scheme() {
serde_json::json!([{"indexAccessToken": []}, {"bearerGrant": []}])
);
}

/// Every operation names a tag and a summary, answers with at least one described response, and every
/// JSON body it describes carries an example or a schema. Checked document-wide rather than per route,
/// so a builder that quietly returns an empty operation, example, or request body fails here regardless
/// of which route it serves. Binary bodies are exempt: an octet stream has no example worth printing.
#[test]
fn test_every_operation_is_fully_described() {
let spec = serde_json::to_value(openapi()).unwrap();

let mut described = Vec::new();
for (path, item) in spec["paths"].as_object().unwrap() {
for (method, operation) in item.as_object().unwrap() {
let responses = operation["responses"].as_object().unwrap();
let body = operation.get("requestBody");
let checks = [
("tags", operation["tags"].as_array().is_none_or(Vec::is_empty)),
("summary", operation["summary"].as_str().is_none_or(str::is_empty)),
("responses", responses.is_empty()),
(
"requestBody content",
body.is_some_and(|body| body["content"].as_object().is_none_or(serde_json::Map::is_empty)),
),
];
let missing: Vec<String> = checks
.into_iter()
.map(|(label, missing)| (label.to_owned(), missing))
.chain(responses.iter().map(|(status, response)| {
(
format!("{status} description"),
response["description"].as_str().is_none_or(str::is_empty),
)
}))
.filter_map(|(label, missing)| missing.then_some(label))
.chain(
responses
.iter()
.flat_map(|(status, response)| undescribed_content(&response["content"], status)),
)
.chain(
body.into_iter()
.flat_map(|body| undescribed_content(&body["content"], "requestBody")),
)
.collect();
described.push((path.clone(), method.clone(), missing));
}
}

let undescribed: Vec<_> = described
.into_iter()
.filter(|(.., missing)| !missing.is_empty())
.collect();
assert_eq!(undescribed, Vec::new());
}

fn undescribed_content(content: &serde_json::Value, owner: &str) -> Vec<String> {
content
.as_object()
.into_iter()
.flatten()
.map(|(media_type, media)| {
(
format!("{owner} {media_type} example"),
media_type.contains("json") && media["example"].is_null() && media["schema"].is_null(),
)
})
.filter_map(|(label, missing)| missing.then_some(label))
.collect()
}

/// The trash document uses the handler's wire names: the record examples carry exactly the fields it
/// writes, and the inspect query names the parameters it reads, so a reader building against the
/// document does not send `name` for `resource` or `reference` for `artifact`.
#[test]
fn test_trash_document_uses_the_wire_names() {
let spec = serde_json::to_value(openapi()).unwrap();
let example =
|path: &str| spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["example"].clone();
let expected = BTreeSet::from([
"actor",
"artifact",
"deadline_unix",
"deleted_at_unix",
"digest",
"ecosystem",
"reason",
"repository",
"resource",
"restorable",
"state",
]);

for record in [
example("/+trash")["trash"][0].clone(),
example("/+trash/record")["record"].clone(),
] {
let keys: BTreeSet<&str> = record.as_object().unwrap().keys().map(String::as_str).collect();
assert_eq!(keys, expected);
}
let query: BTreeSet<&str> = spec["paths"]["/+trash/record"]["get"]["parameters"]
.as_array()
.unwrap()
.iter()
.map(|parameter| parameter["name"].as_str().unwrap())
.collect();
assert_eq!(
query,
BTreeSet::from(["artifact", "digest", "ecosystem", "repository", "resource"])
);
}

/// The nested objects the analytics, quota and retention examples share are real records, not
/// placeholders: every analytics view shows the same resolved window, every quota meter carries all
/// four counters, and a retention candidate names what the plan decided about it.
#[test]
fn test_shared_example_records_carry_their_fields() {
let spec = serde_json::to_value(openapi()).unwrap();
let example = |path: &str, method: &str| {
spec["paths"][path][method]["responses"]["200"]["content"]["application/json"]["example"].clone()
};
let keys = |value: &serde_json::Value| -> BTreeSet<String> { value.as_object().unwrap().keys().cloned().collect() };
let interval = BTreeSet::from(
[
"from_day",
"to_day",
"from_unix",
"to_unix",
"retained_from_day",
"window_clamped_to_retention",
]
.map(str::to_owned),
);
let meter = BTreeSet::from(["committed", "reserved", "limit", "remaining"].map(str::to_owned));

for view in ["top-resources", "unused", "groups", "sources", "timeline"] {
assert_eq!(
keys(&example(&format!("/+analytics/{view}"), "get")["interval"]),
interval,
"{view}"
);
}
let quota = example("/+quota/repository", "get");
for counter in ["artifact_bytes", "accounted_bytes", "resources"] {
assert_eq!(keys(&quota[counter]), meter, "{counter}");
}
let candidate = BTreeSet::from(
[
"resource",
"group",
"artifact",
"digest",
"class",
"visibility",
"bytes",
"outcome",
"rule",
"retained_groups",
]
.map(str::to_owned),
);
assert_eq!(keys(&example("/+retention/plan", "post")["candidates"][0]), candidate);
}
9 changes: 8 additions & 1 deletion crates/peryx/tests/unit/tests/app/cache_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,15 @@ fn test_cache_list_filters_index_pages() {
let plugins = plugins();
let (_directory, meta, config) = store_and_config(&plugins);
drop(meta);
let cases: [(&str, CacheListArgs, &[&str]); 6] = [
// The gadget page was fetched at the epoch, so its age is the wall clock itself: a threshold of
// decades passes only when the clock the listing reads is real rather than a placeholder.
let cases: [(&str, CacheListArgs, &[&str]); 7] = [
("index", page_args(Some("other"), None, false, None, None), &[]),
(
"minimum age of decades",
page_args(None, None, false, Some(1_000_000_000), None),
&["gadget"],
),
("resource", page_args(None, Some("other"), false, None, None), &[]),
(
"normalized resource",
Expand Down
16 changes: 16 additions & 0 deletions crates/peryx/tests/unit/tests/app/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ fn test_config_check_reports_the_listener_scheme(#[case] tls: Option<TlsConfig>,
);
}

#[rstest]
#[case::none(0, "0 configured indexes")]
#[case::one(1, "1 configured index")]
#[case::several(2, "2 configured indexes")]
fn test_config_check_counts_indexes_with_the_right_number(#[case] count: usize, #[case] expected: &str) {
let mut config = Config::default();
config.indexes.truncate(count);
assert_eq!(config.indexes.len(), count);
let mut output = Vec::new();

config_check(&config, &mut output).unwrap();

let output = String::from_utf8(output).unwrap();
assert!(output.contains(&format!(" indexes: {expected}\n")), "{output}");
}

#[test]
fn test_config_check_reports_a_bare_ipv6_listener() {
let config = Config {
Expand Down
6 changes: 3 additions & 3 deletions crates/peryx/tests/unit/tests/app/fsck_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn test_cache_fsck_includes_plugin_metadata_problems() {

cache_with_plugins(&config, &plugins, &command(), &mut output).unwrap();

assert_eq!(output, b"metadata\tcore\tinvalid\nproblems\t1\n");
assert_eq!(output, b"metadata\tcore\tinvalid\tmain\nproblems\t1\n");
}

#[test]
Expand Down Expand Up @@ -174,7 +174,7 @@ fn test_cache_repair_previews_the_records_a_rebuild_would_write() {

cache_with_plugins(&config, &plugins, &repair_command(false), &mut output).unwrap();

assert_eq!(output, b"metadata\tcore\twould rebuild\nplanned\t1\n");
assert_eq!(output, b"metadata\tcore\twould rebuild\tmain\nplanned\t1\n");
}

#[test]
Expand All @@ -190,7 +190,7 @@ fn test_cache_repair_rebuilds_when_confirmed() {

cache_with_plugins(&config, &plugins, &repair_command(true), &mut output).unwrap();

assert_eq!(output, b"metadata\tcore\trebuilt\nrepaired\t1\n");
assert_eq!(output, b"metadata\tcore\trebuilt\tmain\nrepaired\t1\n");
}

#[test]
Expand Down
Loading