release: add Web Graph authority evidence for v0.6
Some checks failed
Standalone registry checks / check (push) Has been cancelled
Some checks failed
Standalone registry checks / check (push) Has been cancelled
This commit is contained in:
parent
16c740a98f
commit
3e0cc1bc50
21 changed files with 698 additions and 15 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.6.0 - 2026-09-22
|
||||||
|
|
||||||
|
- Add a streaming Common Crawl domain Web Graph adapter for harmonic-centrality,
|
||||||
|
PageRank, and member-host evidence while preserving exact provider fields and
|
||||||
|
source-line coordinates.
|
||||||
|
- Authenticate and validate the complete rank stream but retain only registrable
|
||||||
|
domains already asserted by imported public identity sources, avoiding a
|
||||||
|
multi-gigabyte runtime catalog whose unrelated rows cannot resolve routes.
|
||||||
|
- Bind every compact graph projection to the SHA-256 of its sorted candidate
|
||||||
|
domains and fail closed when identity evidence, the PSL, the bound scope, or a
|
||||||
|
selected graph row is absent.
|
||||||
|
- Teach scheduled updates to import identity sources before automatically binding,
|
||||||
|
downloading, and importing Web Graph evidence with `{candidate_domains}`.
|
||||||
|
- Strictly allowlist official HTTPS domain-rank objects and retain Common Crawl
|
||||||
|
Terms-of-Use attribution without treating authority as ownership, safety,
|
||||||
|
reviewer approval, or query popularity.
|
||||||
|
- Update Rustls to 0.23.45, remediating RUSTSEC-2026-0285 in the dataset
|
||||||
|
acquisition path.
|
||||||
|
|
||||||
## 0.5.0 - 2026-09-13
|
## 0.5.0 - 2026-09-13
|
||||||
|
|
||||||
- Add a streaming ROR 2.1 ZIP adapter with exact schema checks, declared domains,
|
- Add a streaming ROR 2.1 ZIP adapter with exact schema checks, declared domains,
|
||||||
|
|
|
||||||
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -78,14 +78,14 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argand-atomic"
|
name = "argand-atomic"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argand-site-registry"
|
name = "argand-site-registry"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argand-atomic",
|
"argand-atomic",
|
||||||
|
|
@ -1513,9 +1513,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ resolver = "3"
|
||||||
members = ["crates/argand-atomic", "crates/argand-site-registry"]
|
members = ["crates/argand-atomic", "crates/argand-site-registry"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
authors = ["Nic Weyand"]
|
authors = ["Nic Weyand"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,13 @@ CLI and preserves its JSON contract.
|
||||||
| Chrome UX Report | origin popularity bucket, month, optional audience country | CC BY 4.0 International |
|
| Chrome UX Report | origin popularity bucket, month, optional audience country | CC BY 4.0 International |
|
||||||
| Curlie | site titles, categories, descriptions retained for audit | CC BY 3.0 Unported |
|
| Curlie | site titles, categories, descriptions retained for audit | CC BY 3.0 Unported |
|
||||||
| Public Suffix List | ICANN and PRIVATE suffix rules | MPL 2.0 |
|
| Public Suffix List | ICANN and PRIVATE suffix rules | MPL 2.0 |
|
||||||
|
| Common Crawl Web Graph | domain harmonic-centrality/PageRank and member-host count | Common Crawl Terms of Use (`LicenseRef-Common-Crawl-Terms-of-Use`) |
|
||||||
|
|
||||||
Popularity never proves identity or ownership. Curlie attribution applies to
|
Popularity never proves identity or ownership. Curlie attribution applies to
|
||||||
names and categories as well as descriptions; copied descriptions are redacted
|
names and categories as well as descriptions; copied descriptions are redacted
|
||||||
unless the caller explicitly exports them and satisfies the display obligations.
|
from compact display surfaces unless the caller explicitly exports them and
|
||||||
|
satisfies the display obligations. The scheduled updater can bind the Web Graph
|
||||||
|
to the exact public-identity domain frontier; it never approves or activates routes.
|
||||||
Read [LICENSE_SOURCES.md](LICENSE_SOURCES.md) before distributing provider data.
|
Read [LICENSE_SOURCES.md](LICENSE_SOURCES.md) before distributing provider data.
|
||||||
|
|
||||||
Cloudflare Radar, default Tranco, Cisco Umbrella, arbitrary mirrors, and sources
|
Cloudflare Radar, default Tranco, Cisco Umbrella, arbitrary mirrors, and sources
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ listing is evidence of an assertion, not a guarantee of ownership or safety.
|
||||||
| Chrome UX Report (CrUX), Google | [CC BY 4.0 International](https://creativecommons.org/licenses/by/4.0/), [`CC-BY-4.0`](https://developer.chrome.com/docs/crux/methodology) | [Monthly BigQuery dataset](https://developer.chrome.com/docs/crux/bigquery/): `origin`, `experimental.popularity.rank`, observation month, optional audience-country dataset code. The adapter produces `origin,rank,yyyymm,country_code` CSV. Rank is a coarse bucket, not a precise visit count. Audience country is not website jurisdiction. No API key or OAuth token is retained. |
|
| Chrome UX Report (CrUX), Google | [CC BY 4.0 International](https://creativecommons.org/licenses/by/4.0/), [`CC-BY-4.0`](https://developer.chrome.com/docs/crux/methodology) | [Monthly BigQuery dataset](https://developer.chrome.com/docs/crux/bigquery/): `origin`, `experimental.popularity.rank`, observation month, optional audience-country dataset code. The adapter produces `origin,rank,yyyymm,country_code` CSV. Rank is a coarse bucket, not a precise visit count. Audience country is not website jurisdiction. No API key or OAuth token is retained. |
|
||||||
| Curlie | [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/), [`CC-BY-3.0`](https://curlie.org/docs/en/license.html), including the attribution placement prescribed on that page | [Format documentation](https://curlie.org/docs/en/rdf.html), [official download redirect](https://curlie.org/directory-dl), currently [Passau-hosted archive](https://share.innkube.fim.uni-passau.de/curlie-rdf/curlie-rdf-all.tar.gz). Despite its RDF name, the current archive contains **literal TSV**. Content: URL, title, description, category ID. Structure: category ID, full category path, entry count, description, latitude, longitude. Archive notices are retained. |
|
| Curlie | [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/), [`CC-BY-3.0`](https://curlie.org/docs/en/license.html), including the attribution placement prescribed on that page | [Format documentation](https://curlie.org/docs/en/rdf.html), [official download redirect](https://curlie.org/directory-dl), currently [Passau-hosted archive](https://share.innkube.fim.uni-passau.de/curlie-rdf/curlie-rdf-all.tar.gz). Despite its RDF name, the current archive contains **literal TSV**. Content: URL, title, description, category ID. Structure: category ID, full category path, entry count, description, latitude, longitude. Archive notices are retained. |
|
||||||
| Public Suffix List contributors | [Mozilla Public License 2.0](https://mozilla.org/MPL/2.0/), [`MPL-2.0`](https://publicsuffix.org/list/public_suffix_list.dat) | [Official list](https://publicsuffix.org/list/public_suffix_list.dat). All ICANN and PRIVATE rules, wildcard/exception rules, version/commit comments and notices. Used for hostname, registrable-domain and public-suffix derivations. Download at most once per day. |
|
| Public Suffix List contributors | [Mozilla Public License 2.0](https://mozilla.org/MPL/2.0/), [`MPL-2.0`](https://publicsuffix.org/list/public_suffix_list.dat) | [Official list](https://publicsuffix.org/list/public_suffix_list.dat). All ICANN and PRIVATE rules, wildcard/exception rules, version/commit comments and notices. Used for hostname, registrable-domain and public-suffix derivations. Download at most once per day. |
|
||||||
|
| Common Crawl Web Graph | [Common Crawl Terms of Use](https://commoncrawl.org/terms-of-use), `LicenseRef-Common-Crawl-Terms-of-Use` | [Official Web Graph releases](https://index.commoncrawl.org/web-graphs-index.html). The domain-rank adapter consumes the exact six-column rank file: harmonic-centrality rank/value, PageRank rank/value, reversed registered domain, and provider `n_hosts`. `n_hosts` is retained as `member_hosts`; it is not represented as inbound-linking hosts. This is authority/popularity evidence only and never establishes entity ownership, query popularity, safety, or route approval. |
|
||||||
| Argand candidate observer | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/), `CC0-1.0` | Local host-side observations authored by the registry publisher: HTTP status and redirect targets, canonical/hreflang/JSON-LD/sitemap/country-selector targets, public DNS-set hash, TLS leaf-certificate hash, bounded failure class, content hash and selectors. These records describe a capture; they do not incorporate page prose or prove ownership. |
|
| Argand candidate observer | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/), `CC0-1.0` | Local host-side observations authored by the registry publisher: HTTP status and redirect targets, canonical/hreflang/JSON-LD/sitemap/country-selector targets, public DNS-set hash, TLS leaf-certificate hash, bounded failure class, content hash and selectors. These records describe a capture; they do not incorporate page prose or prove ownership. |
|
||||||
|
|
||||||
## Attribution and distribution
|
## Attribution and distribution
|
||||||
|
|
@ -56,6 +57,12 @@ listing is evidence of an assertion, not a guarantee of ownership or safety.
|
||||||
and `facts`; exports include the PSL fact and original download locator.
|
and `facts`; exports include the PSL fact and original download locator.
|
||||||
Changes to covered PSL source files must remain available under MPL 2.0.
|
Changes to covered PSL source files must remain available under MPL 2.0.
|
||||||
The Rust `publicsuffix` parser is MIT/Apache-2.0; that is separate from the list.
|
The Rust `publicsuffix` parser is MIT/Apache-2.0; that is separate from the list.
|
||||||
|
* **Common Crawl Web Graph:** retain the exact release and object URL, retrieval
|
||||||
|
time, content digest, Terms of Use link, and identify Argand's reversed-domain
|
||||||
|
projection. Common Crawl's Terms are not an SPDX open-data license and may
|
||||||
|
change; re-review them for every new acquisition. The rank file describes a
|
||||||
|
crawl-derived graph and does not transfer rights in crawled pages. Do not use
|
||||||
|
rank alone to assert ownership, safety, trust, or an official destination.
|
||||||
* **Argand observer:** locally produced observation metadata is dedicated under
|
* **Argand observer:** locally produced observation metadata is dedicated under
|
||||||
CC0. The fetched page remains subject to its own rights. The default observer
|
CC0. The fetched page remains subject to its own rights. The default observer
|
||||||
stores only a bounded body in the local replay cache and emits normalized link,
|
stores only a bounded body in the local replay cache and emits normalized link,
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,36 @@ The month belongs in the source snapshot identity. With scheduled typed
|
||||||
supersession enabled, the next month then replaces the same audience partition
|
supersession enabled, the next month then replaces the same audience partition
|
||||||
instead of accumulating stale popularity facts.
|
instead of accumulating stale popularity facts.
|
||||||
|
|
||||||
|
### Common Crawl Web Graph
|
||||||
|
|
||||||
|
The adapter accepts the official domain-level rank object whose exact header is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
It reverses `host_rev` (`com.facebook` to `facebook.com`) and retains harmonic
|
||||||
|
centrality, PageRank and `n_hosts` as source-separated popularity evidence.
|
||||||
|
`n_hosts` means hosts belonging to the registered domain and is exposed as
|
||||||
|
`member_hosts`; it is not an inbound-link count. The adapter creates no entity,
|
||||||
|
name, official-site edge, review, vote or route.
|
||||||
|
|
||||||
|
Only exact domain-rank objects below the official
|
||||||
|
`data.commoncrawl.org/projects/hyperlinkgraph/<release>/domain/` hierarchy are
|
||||||
|
allowlisted. Use `compression: gzip`, the exact release ID as the snapshot, a
|
||||||
|
positive byte ceiling, and typed coverage. The downloader and importer hash and
|
||||||
|
consume the complete object; a byte-range prefix must not be declared as the
|
||||||
|
complete source. Common Crawl's Terms of Use are not an SPDX open-data license,
|
||||||
|
so preserve the Terms link and re-review it on each acquisition.
|
||||||
|
|
||||||
|
Domain-rank files contain tens of millions of rows. Import the PSL and public
|
||||||
|
identity sources first, then run `web-graph-selection --database ...` to obtain
|
||||||
|
the exact `candidate-domains:<sha256>` scope. A scheduled update may instead use
|
||||||
|
the `{candidate_domains}` token. The importer still parses and authenticates the
|
||||||
|
complete stream but persists only matching registrable domains, with original
|
||||||
|
row coordinates. This keeps the public updater reproducible and compact. Rank
|
||||||
|
still cannot replace reviewer quorum or destination-safety checks.
|
||||||
|
|
||||||
## Build, inspect and review
|
## Build, inspect and review
|
||||||
|
|
||||||
The strict default requires an independently maintained OpenSSH reviewer trust
|
The strict default requires an independently maintained OpenSSH reviewer trust
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,19 @@ collection = "default"
|
||||||
kind = "full"
|
kind = "full"
|
||||||
supersedes = []
|
supersedes = []
|
||||||
|
|
||||||
|
# Optional Common Crawl Web Graph authority evidence. The updater imports
|
||||||
|
# identity sources first, replaces this token with their exact sorted-domain
|
||||||
|
# digest, authenticates the complete graph stream, and retains only matching
|
||||||
|
# registrable domains. Popularity never authorizes a redirect.
|
||||||
|
# [[downloads]]
|
||||||
|
# source = "common_crawl_web_graph"
|
||||||
|
# format = "common_crawl_domain_ranks_tsv"
|
||||||
|
# compression = "gzip"
|
||||||
|
# url = "https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz"
|
||||||
|
# snapshot = "cc-main-2022-may-jun-aug"
|
||||||
|
# scope = "{candidate_domains}"
|
||||||
|
# maximum_bytes = 3000000000
|
||||||
|
|
||||||
# Optional pinned acquisitions; repeat [[inputs]] for each source.
|
# Optional pinned acquisitions; repeat [[inputs]] for each source.
|
||||||
# [[inputs]]
|
# [[inputs]]
|
||||||
# input = "/data/source-object.gz"
|
# input = "/data/source-object.gz"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
// By Nic Weyand!
|
||||||
|
//! Streaming Common Crawl domain-rank projection; graph rank never creates identity.
|
||||||
|
|
||||||
|
use super::{RecordSink, SourceAdapter, bounded_line};
|
||||||
|
use crate::model::{Fact, Record};
|
||||||
|
use anyhow::ensure;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{collections::BTreeSet, io::BufRead};
|
||||||
|
|
||||||
|
const HEADER: [&str; 6] = [
|
||||||
|
"#harmonicc_pos",
|
||||||
|
"#harmonicc_val",
|
||||||
|
"#pr_pos",
|
||||||
|
"#pr_val",
|
||||||
|
"#host_rev",
|
||||||
|
"#n_hosts",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(super) struct DomainRanks {
|
||||||
|
pub(super) targets: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceAdapter for DomainRanks {
|
||||||
|
fn ingest(&self, input: &mut dyn BufRead, sink: &mut dyn RecordSink) -> anyhow::Result<()> {
|
||||||
|
let mut line = String::new();
|
||||||
|
ensure!(bounded_line(input, &mut line)? > 0, "empty domain-rank TSV");
|
||||||
|
ensure!(columns(&line)? == HEADER, "domain-rank TSV schema changed");
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
!self.targets.is_empty(),
|
||||||
|
"Web Graph candidate-domain selection is empty"
|
||||||
|
);
|
||||||
|
let mut source_row = 0_u64;
|
||||||
|
let mut emitted = 0_u64;
|
||||||
|
while bounded_line(input, &mut line)? > 0 {
|
||||||
|
ensure!(!line.trim().is_empty(), "blank domain-rank row");
|
||||||
|
let fields = columns(&line)?;
|
||||||
|
ensure!(
|
||||||
|
fields.len() == HEADER.len(),
|
||||||
|
"domain-rank column count changed"
|
||||||
|
);
|
||||||
|
let harmonic_rank = positive_integer(fields[0], "harmonic rank")?;
|
||||||
|
let harmonic_value = nonnegative_finite(fields[1], "harmonic value")?;
|
||||||
|
let pagerank_rank = positive_integer(fields[2], "PageRank rank")?;
|
||||||
|
let pagerank_value = nonnegative_finite(fields[3], "PageRank value")?;
|
||||||
|
let target = reverse_domain(fields[4])?;
|
||||||
|
let member_hosts = positive_integer(fields[5], "member host count")?;
|
||||||
|
source_row += 1;
|
||||||
|
if !self.targets.contains(&target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emitted += 1;
|
||||||
|
let raw = json!({
|
||||||
|
"harmonicc_pos": fields[0],
|
||||||
|
"harmonicc_val": fields[1],
|
||||||
|
"pr_pos": fields[2],
|
||||||
|
"pr_val": fields[3],
|
||||||
|
"host_rev": fields[4],
|
||||||
|
"n_hosts": fields[5],
|
||||||
|
});
|
||||||
|
sink.emit(Record {
|
||||||
|
native_id: format!("row:{source_row}"),
|
||||||
|
raw,
|
||||||
|
facts: vec![Fact {
|
||||||
|
subject: target.clone(),
|
||||||
|
predicate: "popularity".into(),
|
||||||
|
value: json!({
|
||||||
|
"target": target,
|
||||||
|
"target_kind": "hostname",
|
||||||
|
"harmonic_rank": harmonic_rank,
|
||||||
|
"harmonic_value": harmonic_value,
|
||||||
|
"pagerank_rank": pagerank_rank,
|
||||||
|
"pagerank_value": pagerank_value,
|
||||||
|
// Provider n_hosts counts hosts belonging to this domain. It is
|
||||||
|
// not a count of distinct domains or hosts linking to the target.
|
||||||
|
"member_hosts": member_hosts,
|
||||||
|
"country_code": null,
|
||||||
|
"period": null,
|
||||||
|
}),
|
||||||
|
selector: format!("row:{source_row}"),
|
||||||
|
confidence: 10_000,
|
||||||
|
}],
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
ensure!(source_row > 0, "empty domain-rank dataset");
|
||||||
|
ensure!(
|
||||||
|
emitted > 0,
|
||||||
|
"Web Graph contains none of the selected candidate domains"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn columns(line: &str) -> anyhow::Result<Vec<&str>> {
|
||||||
|
let line = line.strip_suffix('\n').unwrap_or(line);
|
||||||
|
let line = line.strip_suffix('\r').unwrap_or(line);
|
||||||
|
ensure!(
|
||||||
|
!line
|
||||||
|
.chars()
|
||||||
|
.any(|character| character.is_control() && character != '\t'),
|
||||||
|
"control in domain-rank row"
|
||||||
|
);
|
||||||
|
Ok(line.split('\t').collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive_integer(value: &str, field: &str) -> anyhow::Result<u64> {
|
||||||
|
let parsed: u64 = value.parse()?;
|
||||||
|
ensure!(parsed > 0, "{field} must be positive");
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonnegative_finite(value: &str, field: &str) -> anyhow::Result<f64> {
|
||||||
|
let parsed: f64 = value.parse()?;
|
||||||
|
ensure!(
|
||||||
|
parsed.is_finite() && parsed >= 0.0,
|
||||||
|
"{field} must be finite and nonnegative"
|
||||||
|
);
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reverse_domain(value: &str) -> anyhow::Result<String> {
|
||||||
|
ensure!(
|
||||||
|
!value.is_empty() && value.len() <= 253 && value == value.trim(),
|
||||||
|
"invalid reversed domain"
|
||||||
|
);
|
||||||
|
let labels = value.split('.').collect::<Vec<_>>();
|
||||||
|
ensure!(
|
||||||
|
labels.len() >= 2,
|
||||||
|
"reversed domain needs at least two labels"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
labels.iter().all(|label| {
|
||||||
|
!label.is_empty()
|
||||||
|
&& label.len() <= 63
|
||||||
|
&& label
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
|
&& label
|
||||||
|
.as_bytes()
|
||||||
|
.first()
|
||||||
|
.is_some_and(u8::is_ascii_alphanumeric)
|
||||||
|
&& label
|
||||||
|
.as_bytes()
|
||||||
|
.last()
|
||||||
|
.is_some_and(u8::is_ascii_alphanumeric)
|
||||||
|
}),
|
||||||
|
"invalid reversed domain label"
|
||||||
|
);
|
||||||
|
Ok(labels.into_iter().rev().collect::<Vec<_>>().join("."))
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
use crate::model::{Fact, Format, Record};
|
use crate::model::{Fact, Format, Record};
|
||||||
use anyhow::ensure;
|
use anyhow::ensure;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::io::{BufRead, Read};
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
io::{BufRead, Read},
|
||||||
|
};
|
||||||
|
mod common_crawl_web_graph;
|
||||||
pub(crate) mod csv_sources;
|
pub(crate) mod csv_sources;
|
||||||
mod curlie;
|
mod curlie;
|
||||||
mod ror;
|
mod ror;
|
||||||
|
|
@ -39,6 +43,7 @@ pub fn adapter(
|
||||||
format: Format,
|
format: Format,
|
||||||
maximum_record_bytes: usize,
|
maximum_record_bytes: usize,
|
||||||
coverage_delta: bool,
|
coverage_delta: bool,
|
||||||
|
web_graph_targets: Option<BTreeSet<String>>,
|
||||||
) -> Box<dyn SourceAdapter> {
|
) -> Box<dyn SourceAdapter> {
|
||||||
match format {
|
match format {
|
||||||
Format::WikidataDump => Box::new(wikidata::Wikidata {
|
Format::WikidataDump => Box::new(wikidata::Wikidata {
|
||||||
|
|
@ -58,6 +63,9 @@ pub fn adapter(
|
||||||
Format::RorZip => Box::new(ror::Ror {
|
Format::RorZip => Box::new(ror::Ror {
|
||||||
maximum_record_bytes,
|
maximum_record_bytes,
|
||||||
}),
|
}),
|
||||||
|
Format::CommonCrawlDomainRanksTsv => Box::new(common_crawl_web_graph::DomainRanks {
|
||||||
|
targets: web_graph_targets.unwrap_or_default(),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,11 @@ enum Command {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
maximum_database_growth_bytes: Option<u64>,
|
maximum_database_growth_bytes: Option<u64>,
|
||||||
},
|
},
|
||||||
|
/// Compute the exact candidate-domain scope for a compact Web Graph import.
|
||||||
|
WebGraphSelection {
|
||||||
|
#[arg(long)]
|
||||||
|
database: PathBuf,
|
||||||
|
},
|
||||||
/// Build a new immutable generation; output must not exist.
|
/// Build a new immutable generation; output must not exist.
|
||||||
Build {
|
Build {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|
@ -739,6 +744,9 @@ pub(super) async fn run() -> anyhow::Result<()> {
|
||||||
maximum_records,
|
maximum_records,
|
||||||
maximum_database_growth_bytes,
|
maximum_database_growth_bytes,
|
||||||
)?,
|
)?,
|
||||||
|
Command::WebGraphSelection { database } => serde_json::to_value(
|
||||||
|
registry::store::web_graph_selection(®istry::store::open(&database)?)?,
|
||||||
|
)?,
|
||||||
Command::ObservationImport {
|
Command::ObservationImport {
|
||||||
database,
|
database,
|
||||||
generation,
|
generation,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ pub fn validate_source_url(source: Source, input: &str) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
Source::Psl => host == "publicsuffix.org" && path == "/list/public_suffix_list.dat",
|
Source::Psl => host == "publicsuffix.org" && path == "/list/public_suffix_list.dat",
|
||||||
Source::Ror => ror_url(host, path),
|
Source::Ror => ror_url(host, path),
|
||||||
|
Source::CommonCrawlWebGraph => common_crawl_domain_ranks_url(host, path),
|
||||||
};
|
};
|
||||||
ensure!(allowed, "unreviewed source endpoint: {host}{path}");
|
ensure!(allowed, "unreviewed source endpoint: {host}{path}");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -130,6 +131,10 @@ pub async fn download(cache: &Path, request: &Download) -> anyhow::Result<Cached
|
||||||
);
|
);
|
||||||
validate_source_url(request.source, &request.url)?;
|
validate_source_url(request.source, &request.url)?;
|
||||||
ensure!(request.maximum_bytes > 0, "maximum bytes must be positive");
|
ensure!(request.maximum_bytes > 0, "maximum bytes must be positive");
|
||||||
|
ensure!(
|
||||||
|
request.scope != "{candidate_domains}",
|
||||||
|
"candidate-domain scope token is resolved only by the ordered update command"
|
||||||
|
);
|
||||||
let requested_proof = requested_checksum_proof(request)?;
|
let requested_proof = requested_checksum_proof(request)?;
|
||||||
let key = crate::digest(&serde_json::to_vec(request)?);
|
let key = crate::digest(&serde_json::to_vec(request)?);
|
||||||
let dir = cache.join(request.source.key()).join(key);
|
let dir = cache.join(request.source.key()).join(key);
|
||||||
|
|
@ -267,6 +272,25 @@ fn ror_url(host: &str, path: &str) -> bool {
|
||||||
&& components[5] == "content"
|
&& components[5] == "content"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn common_crawl_domain_ranks_url(host: &str, path: &str) -> bool {
|
||||||
|
let components = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
|
||||||
|
if host != "data.commoncrawl.org"
|
||||||
|
|| components.len() != 5
|
||||||
|
|| components[0] != "projects"
|
||||||
|
|| components[1] != "hyperlinkgraph"
|
||||||
|
|| components[3] != "domain"
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let release = components[2];
|
||||||
|
release.starts_with("cc-main-")
|
||||||
|
&& release.len() <= 128
|
||||||
|
&& release
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
|
&& components[4] == format!("{release}-domain-ranks.txt.gz")
|
||||||
|
}
|
||||||
|
|
||||||
fn requested_checksum_proof(request: &Download) -> anyhow::Result<Option<IntegrityProof>> {
|
fn requested_checksum_proof(request: &Download) -> anyhow::Result<Option<IntegrityProof>> {
|
||||||
match (&request.provider_checksum, &request.provider_checksum_url) {
|
match (&request.provider_checksum, &request.provider_checksum_url) {
|
||||||
(None, None) => Ok(None),
|
(None, None) => Ok(None),
|
||||||
|
|
@ -327,7 +351,11 @@ pub(crate) fn validate_integrity_evidence(
|
||||||
&& evidence.path().rsplit_once('/').map(|item| item.0) == source_parent
|
&& evidence.path().rsplit_once('/').map(|item| item.0) == source_parent
|
||||||
&& evidence.path().ends_with("/sha256sums.txt")
|
&& evidence.path().ends_with("/sha256sums.txt")
|
||||||
}
|
}
|
||||||
Source::Majestic | Source::Crux | Source::Curlie | Source::Psl => false,
|
Source::Majestic
|
||||||
|
| Source::Crux
|
||||||
|
| Source::Curlie
|
||||||
|
| Source::Psl
|
||||||
|
| Source::CommonCrawlWebGraph => false,
|
||||||
};
|
};
|
||||||
ensure!(allowed, "unreviewed provider checksum evidence endpoint");
|
ensure!(allowed, "unreviewed provider checksum evidence endpoint");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -536,6 +564,29 @@ fn reserve_psl_refresh(cache: &Path) -> anyhow::Result<()> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn web_graph_scope_token_requires_ordered_update() -> anyhow::Result<()> {
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let request = Download {
|
||||||
|
source: Source::CommonCrawlWebGraph,
|
||||||
|
format: Format::CommonCrawlDomainRanksTsv,
|
||||||
|
compression: Compression::Gzip,
|
||||||
|
url: "https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz".into(),
|
||||||
|
snapshot: "cc-main-2022-may-jun-aug".into(),
|
||||||
|
scope: "{candidate_domains}".into(),
|
||||||
|
maximum_bytes: 3_000_000_000,
|
||||||
|
maximum_record_bytes: None,
|
||||||
|
coverage: None,
|
||||||
|
provider_checksum: None,
|
||||||
|
provider_checksum_url: None,
|
||||||
|
};
|
||||||
|
let Some(error) = download(root.path(), &request).await.err() else {
|
||||||
|
anyhow::bail!("scope token accepted outside update");
|
||||||
|
};
|
||||||
|
assert!(error.to_string().contains("ordered update command"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validator_bound_ranges_and_source_allowlist() -> anyhow::Result<()> {
|
fn validator_bound_ranges_and_source_allowlist() -> anyhow::Result<()> {
|
||||||
let url = "https://downloads.majestic.com/majestic_million.csv";
|
let url = "https://downloads.majestic.com/majestic_million.csv";
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ pub enum Source {
|
||||||
Psl,
|
Psl,
|
||||||
/// Research Organization Registry organization records.
|
/// Research Organization Registry organization records.
|
||||||
Ror,
|
Ror,
|
||||||
|
/// Common Crawl domain-level Web Graph ranks.
|
||||||
|
CommonCrawlWebGraph,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Source {
|
impl Source {
|
||||||
|
|
@ -35,6 +37,7 @@ impl Source {
|
||||||
Self::Curlie => "curlie",
|
Self::Curlie => "curlie",
|
||||||
Self::Psl => "psl",
|
Self::Psl => "psl",
|
||||||
Self::Ror => "ror",
|
Self::Ror => "ror",
|
||||||
|
Self::CommonCrawlWebGraph => "common_crawl_web_graph",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Exact SPDX data license.
|
/// Exact SPDX data license.
|
||||||
|
|
@ -45,6 +48,7 @@ impl Source {
|
||||||
Self::Majestic | Self::Curlie => "CC-BY-3.0",
|
Self::Majestic | Self::Curlie => "CC-BY-3.0",
|
||||||
Self::Crux => "CC-BY-4.0",
|
Self::Crux => "CC-BY-4.0",
|
||||||
Self::Psl => "MPL-2.0",
|
Self::Psl => "MPL-2.0",
|
||||||
|
Self::CommonCrawlWebGraph => "LicenseRef-Common-Crawl-Terms-of-Use",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Authoritative license evidence page.
|
/// Authoritative license evidence page.
|
||||||
|
|
@ -57,6 +61,7 @@ impl Source {
|
||||||
Self::Curlie => "https://curlie.org/docs/en/license.html",
|
Self::Curlie => "https://curlie.org/docs/en/license.html",
|
||||||
Self::Psl => "https://publicsuffix.org/list/public_suffix_list.dat",
|
Self::Psl => "https://publicsuffix.org/list/public_suffix_list.dat",
|
||||||
Self::Ror => "https://ror.readme.io/docs/data-dump",
|
Self::Ror => "https://ror.readme.io/docs/data-dump",
|
||||||
|
Self::CommonCrawlWebGraph => "https://commoncrawl.org/terms-of-use",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +84,8 @@ pub enum Format {
|
||||||
PslText,
|
PslText,
|
||||||
/// Official ROR release ZIP containing schema 2.1 JSON and CSV.
|
/// Official ROR release ZIP containing schema 2.1 JSON and CSV.
|
||||||
RorZip,
|
RorZip,
|
||||||
|
/// Common Crawl's six-column domain-rank TSV.
|
||||||
|
CommonCrawlDomainRanksTsv,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How an immutable source object was checked before import.
|
/// How an immutable source object was checked before import.
|
||||||
|
|
@ -408,8 +415,22 @@ impl SourceManifest {
|
||||||
| (Source::Curlie, Format::CurlieTarGz)
|
| (Source::Curlie, Format::CurlieTarGz)
|
||||||
| (Source::Psl, Format::PslText)
|
| (Source::Psl, Format::PslText)
|
||||||
| (Source::Ror, Format::RorZip)
|
| (Source::Ror, Format::RorZip)
|
||||||
|
| (
|
||||||
|
Source::CommonCrawlWebGraph,
|
||||||
|
Format::CommonCrawlDomainRanksTsv
|
||||||
|
)
|
||||||
);
|
);
|
||||||
ensure!(valid, "source/format mismatch");
|
ensure!(valid, "source/format mismatch");
|
||||||
|
if self.source == Source::CommonCrawlWebGraph {
|
||||||
|
let digest = self
|
||||||
|
.scope
|
||||||
|
.strip_prefix("candidate-domains:")
|
||||||
|
.context("Web Graph scope must bind the candidate-domain digest")?;
|
||||||
|
ensure!(
|
||||||
|
valid_digest(digest),
|
||||||
|
"invalid Web Graph candidate-domain digest"
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(coverage) = &self.coverage {
|
if let Some(coverage) = &self.coverage {
|
||||||
coverage.validate()?;
|
coverage.validate()?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ pub fn attribution() -> Value {
|
||||||
"curlie":{"license":"CC-BY-3.0","credit":"With content from Curlie.org - the largest human-edited directory of the web. Contribute by submitting a website or becoming an editor.","url":"https://curlie.org/","license_url":"https://creativecommons.org/licenses/by/3.0/","public_display":"Use the prescribed HTML attribution on every page using Curlie content: https://curlie.org/docs/en/license.html"},
|
"curlie":{"license":"CC-BY-3.0","credit":"With content from Curlie.org - the largest human-edited directory of the web. Contribute by submitting a website or becoming an editor.","url":"https://curlie.org/","license_url":"https://creativecommons.org/licenses/by/3.0/","public_display":"Use the prescribed HTML attribution on every page using Curlie content: https://curlie.org/docs/en/license.html"},
|
||||||
"psl":{"license":"MPL-2.0","url":"https://publicsuffix.org/list/","license_url":"https://mozilla.org/MPL/2.0/"},
|
"psl":{"license":"MPL-2.0","url":"https://publicsuffix.org/list/","license_url":"https://mozilla.org/MPL/2.0/"},
|
||||||
"ror":{"license":"CC0-1.0","url":"https://ror.org/","license_url":"https://ror.readme.io/docs/data-dump","lineage_note":"ROR location metadata identifies GeoNames as an upstream CC BY 3.0 source","upstream_attribution":{"credit":"GeoNames","url":"https://www.geonames.org/","license_url":"https://creativecommons.org/licenses/by/3.0/"}},
|
"ror":{"license":"CC0-1.0","url":"https://ror.org/","license_url":"https://ror.readme.io/docs/data-dump","lineage_note":"ROR location metadata identifies GeoNames as an upstream CC BY 3.0 source","upstream_attribution":{"credit":"GeoNames","url":"https://www.geonames.org/","license_url":"https://creativecommons.org/licenses/by/3.0/"}},
|
||||||
|
"common_crawl_web_graph":{"license":"LicenseRef-Common-Crawl-Terms-of-Use","credit":"Common Crawl Foundation Web Graph","url":"https://commoncrawl.org/web-graphs","license_url":"https://commoncrawl.org/terms-of-use","scope":"domain-level harmonic centrality, PageRank, and member-host count; graph rank is not ownership or query popularity"},
|
||||||
"argand_candidate_observer":{"license":"CC0-1.0","url":"https://git.argand.org/nicweyand/argand-site-registry","license_url":"https://creativecommons.org/publicdomain/zero/1.0/","scope":"locally authored observation metadata; captured page content is not redistributed"},
|
"argand_candidate_observer":{"license":"CC0-1.0","url":"https://git.argand.org/nicweyand/argand-site-registry","license_url":"https://creativecommons.org/publicdomain/zero/1.0/","scope":"locally authored observation metadata; captured page content is not redistributed"},
|
||||||
"changes":"Argand normalizes and combines assertions; provider endorsement is not implied."})
|
"changes":"Argand normalizes and combines assertions; provider endorsement is not implied."})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
adapters::{self, RecordSink},
|
adapters::{self, RecordSink},
|
||||||
model::{Compression, Record, SourceManifest},
|
model::{Compression, Format, Record, SourceManifest},
|
||||||
|
normalize::Normalizer,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, ensure};
|
use anyhow::{Context, ensure};
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
|
collections::BTreeSet,
|
||||||
io::{BufReader, Read},
|
io::{BufReader, Read},
|
||||||
path::Path,
|
path::Path,
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
|
|
@ -19,6 +22,59 @@ use std::{
|
||||||
/// Adapter/normalization contract recorded in all generation identities.
|
/// Adapter/normalization contract recorded in all generation identities.
|
||||||
pub const RULE_VERSION: &str = "argand.site-rules/v4";
|
pub const RULE_VERSION: &str = "argand.site-rules/v4";
|
||||||
|
|
||||||
|
/// Deterministic Web Graph projection selected from already imported website evidence.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct WebGraphSelection {
|
||||||
|
/// Replacement scope that must be bound into the Web Graph source manifest.
|
||||||
|
pub scope: String,
|
||||||
|
/// Number of distinct registrable domains retained from the graph.
|
||||||
|
pub domains: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the exact public-identity domain set used by a compact Web Graph import.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Requires one completed PSL source and at least one valid website assertion.
|
||||||
|
pub fn web_graph_selection(db: &Connection) -> anyhow::Result<WebGraphSelection> {
|
||||||
|
let (selection, _) = web_graph_targets(db)?;
|
||||||
|
Ok(selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_graph_targets(db: &Connection) -> anyhow::Result<(WebGraphSelection, BTreeSet<String>)> {
|
||||||
|
let (psl_source, encoded): (String, String) = db
|
||||||
|
.query_row(
|
||||||
|
"SELECT f.source_id,f.value FROM facts f JOIN sources s ON s.id=f.source_id WHERE s.complete=1 AND f.predicate='psl' ORDER BY f.source_id LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.context("import a complete PSL snapshot before Common Crawl Web Graph")?;
|
||||||
|
let psl: String = serde_json::from_str(&encoded)?;
|
||||||
|
let normalizer = Normalizer::new(psl.as_bytes(), psl_source)?;
|
||||||
|
let mut statement = db.prepare(
|
||||||
|
"SELECT f.value FROM facts f JOIN sources s ON s.id=f.source_id WHERE s.complete=1 AND f.predicate='website' ORDER BY f.id",
|
||||||
|
)?;
|
||||||
|
let values = statement.query_map([], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut domains = BTreeSet::new();
|
||||||
|
for encoded in values {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&encoded?)?;
|
||||||
|
if let Some(url) = value.get("url").and_then(serde_json::Value::as_str)
|
||||||
|
&& let Ok(property) = normalizer.url(url)
|
||||||
|
{
|
||||||
|
domains.insert(property.domain.registrable_domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
!domains.is_empty(),
|
||||||
|
"import website assertions before Common Crawl Web Graph"
|
||||||
|
);
|
||||||
|
let digest = crate::digest(&serde_json::to_vec(&domains)?);
|
||||||
|
let selection = WebGraphSelection {
|
||||||
|
scope: format!("candidate-domains:{digest}"),
|
||||||
|
domains: u64::try_from(domains.len())?,
|
||||||
|
};
|
||||||
|
Ok((selection, domains))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a signed immutable generation uses a reader-compatible rule contract.
|
/// Whether a signed immutable generation uses a reader-compatible rule contract.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn supported_rule_version(version: &str) -> bool {
|
pub fn supported_rule_version(version: &str) -> bool {
|
||||||
|
|
@ -154,6 +210,17 @@ pub fn import_with_limits(
|
||||||
limits: ImportLimits,
|
limits: ImportLimits,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
manifest.validate()?;
|
manifest.validate()?;
|
||||||
|
let graph_targets = if manifest.format == Format::CommonCrawlDomainRanksTsv {
|
||||||
|
let (selection, targets) = web_graph_targets(db)?;
|
||||||
|
ensure!(
|
||||||
|
manifest.scope == selection.scope,
|
||||||
|
"Web Graph manifest scope does not match current candidate domains; expected {}",
|
||||||
|
selection.scope
|
||||||
|
);
|
||||||
|
Some(targets)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
ensure!(
|
ensure!(
|
||||||
limits.maximum_expanded_bytes > 0
|
limits.maximum_expanded_bytes > 0
|
||||||
&& limits.maximum_records > 0
|
&& limits.maximum_records > 0
|
||||||
|
|
@ -225,6 +292,7 @@ pub fn import_with_limits(
|
||||||
.coverage
|
.coverage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|coverage| coverage.kind == crate::model::CoverageKind::Delta),
|
.is_some_and(|coverage| coverage.kind == crate::model::CoverageKind::Delta),
|
||||||
|
graph_targets,
|
||||||
)
|
)
|
||||||
.ingest(&mut reader, &mut sink);
|
.ingest(&mut reader, &mut sink);
|
||||||
parsed.and_then(|()| {
|
parsed.and_then(|()| {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ pub async fn run(config: &Config) -> anyhow::Result<PathBuf> {
|
||||||
.open(config.generations.join("update.lock"))?;
|
.open(config.generations.join("update.lock"))?;
|
||||||
lock.try_lock().context("registry update already running")?;
|
lock.try_lock().context("registry update already running")?;
|
||||||
let mut inputs = config.inputs.clone();
|
let mut inputs = config.inputs.clone();
|
||||||
|
let mut graph_downloads = Vec::new();
|
||||||
let mut db = crate::store::open(&config.database)?;
|
let mut db = crate::store::open(&config.database)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
for request in &config.downloads {
|
for request in &config.downloads {
|
||||||
|
|
@ -66,6 +67,10 @@ pub async fn run(config: &Config) -> anyhow::Result<PathBuf> {
|
||||||
.snapshot
|
.snapshot
|
||||||
.replace("{date}", &now.format("%Y-%m-%d").to_string())
|
.replace("{date}", &now.format("%Y-%m-%d").to_string())
|
||||||
.replace("{month}", &now.format("%Y-%m").to_string());
|
.replace("{month}", &now.format("%Y-%m").to_string());
|
||||||
|
if request.source == crate::model::Source::CommonCrawlWebGraph {
|
||||||
|
graph_downloads.push(request);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if config.auto_supersede_typed_snapshots
|
if config.auto_supersede_typed_snapshots
|
||||||
&& let Some(coverage) = &mut request.coverage
|
&& let Some(coverage) = &mut request.coverage
|
||||||
&& matches!(
|
&& matches!(
|
||||||
|
|
@ -119,8 +124,31 @@ pub async fn run(config: &Config) -> anyhow::Result<PathBuf> {
|
||||||
}
|
}
|
||||||
inputs.push(crate::crux::download(&config.cache, &request).await?);
|
inputs.push(crate::crux::download(&config.cache, &request).await?);
|
||||||
}
|
}
|
||||||
anyhow::ensure!(!inputs.is_empty(), "update config contains no sources");
|
let mut graph_inputs = Vec::new();
|
||||||
|
let mut identity_inputs = Vec::new();
|
||||||
for input in inputs {
|
for input in inputs {
|
||||||
|
let manifest: SourceManifest = crate::read_json(&input.manifest)?;
|
||||||
|
if manifest.source == crate::model::Source::CommonCrawlWebGraph {
|
||||||
|
graph_inputs.push(input);
|
||||||
|
} else {
|
||||||
|
identity_inputs.push(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
!identity_inputs.is_empty() || !graph_inputs.is_empty() || !graph_downloads.is_empty(),
|
||||||
|
"update config contains no sources"
|
||||||
|
);
|
||||||
|
for input in identity_inputs {
|
||||||
|
let manifest: SourceManifest = crate::read_json(&input.manifest)?;
|
||||||
|
crate::store::import(&mut db, &manifest, &input.input)?;
|
||||||
|
}
|
||||||
|
for mut request in graph_downloads {
|
||||||
|
if request.scope == "{candidate_domains}" {
|
||||||
|
request.scope = crate::store::web_graph_selection(&db)?.scope;
|
||||||
|
}
|
||||||
|
graph_inputs.push(crate::download::download(&config.cache, &request).await?);
|
||||||
|
}
|
||||||
|
for input in graph_inputs {
|
||||||
let manifest: SourceManifest = crate::read_json(&input.manifest)?;
|
let manifest: SourceManifest = crate::read_json(&input.manifest)?;
|
||||||
crate::store::import(&mut db, &manifest, &input.input)?;
|
crate::store::import(&mut db, &manifest, &input.input)?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ pub fn manifest(source: Source, format: Format, bytes: &[u8]) -> anyhow::Result<
|
||||||
Source::Ror => {
|
Source::Ror => {
|
||||||
"https://zenodo.org/api/records/22099990/files/v2.12-2026-08-25-ror-data.zip/content"
|
"https://zenodo.org/api/records/22099990/files/v2.12-2026-08-25-ror-data.zip/content"
|
||||||
}
|
}
|
||||||
|
Source::CommonCrawlWebGraph => {
|
||||||
|
"https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(SourceManifest {
|
Ok(SourceManifest {
|
||||||
schema: "argand.site-source/v1".into(),
|
schema: "argand.site-source/v1".into(),
|
||||||
|
|
|
||||||
239
crates/argand-site-registry/tests/webgraph.rs
Normal file
239
crates/argand-site-registry/tests/webgraph.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// By Nic Weyand!
|
||||||
|
//! Common Crawl Web Graph evidence stays source separated and cannot authorize routes.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // Shared integration helpers intentionally cover a wider fixture surface.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use argand_site_registry::{
|
||||||
|
download::validate_source_url,
|
||||||
|
model::{Compression, Format, Source, SourceManifest},
|
||||||
|
query::ResolutionStatus,
|
||||||
|
store,
|
||||||
|
};
|
||||||
|
use std::{fmt::Write as _, time::Instant};
|
||||||
|
|
||||||
|
const WEBGRAPH: &str = "#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n\
|
||||||
|
1\t3.2914686E7\t1\t0.018076941061056315\tcom.googleapis\t4482\n\
|
||||||
|
2\t3.2131562E7\t3\t0.012273178013351222\tcom.facebook\t18795\n";
|
||||||
|
|
||||||
|
fn import_identity_candidates(
|
||||||
|
db: &mut rusqlite::Connection,
|
||||||
|
root: &std::path::Path,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
common::import(
|
||||||
|
db,
|
||||||
|
root,
|
||||||
|
Source::Psl,
|
||||||
|
Format::PslText,
|
||||||
|
common::PSL.as_bytes(),
|
||||||
|
)?;
|
||||||
|
common::import(
|
||||||
|
db,
|
||||||
|
root,
|
||||||
|
Source::Wikidata,
|
||||||
|
Format::WikidataEntities,
|
||||||
|
&serde_json::to_vec(&common::wikidata())?,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_manifest(db: &rusqlite::Connection, bytes: &[u8]) -> anyhow::Result<SourceManifest> {
|
||||||
|
let mut manifest = common::manifest(
|
||||||
|
Source::CommonCrawlWebGraph,
|
||||||
|
Format::CommonCrawlDomainRanksTsv,
|
||||||
|
bytes,
|
||||||
|
)?;
|
||||||
|
manifest.scope = store::web_graph_selection(db)?.scope;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_graph(
|
||||||
|
db: &mut rusqlite::Connection,
|
||||||
|
root: &std::path::Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let manifest = graph_manifest(db, bytes)?;
|
||||||
|
let input = root.join(format!("{}.graph", manifest.sha256));
|
||||||
|
std::fs::write(&input, bytes)?;
|
||||||
|
store::import(db, &manifest, &input)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_ranks_are_popularity_only_and_preserve_provider_fields() -> anyhow::Result<()> {
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let mut db = store::open(&root.path().join("store.sqlite"))?;
|
||||||
|
import_identity_candidates(&mut db, root.path())?;
|
||||||
|
let identity_before: i64 = db.query_row(
|
||||||
|
"SELECT count(*) FROM facts WHERE predicate NOT IN ('psl','popularity')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
import_graph(&mut db, root.path(), WEBGRAPH.as_bytes())?;
|
||||||
|
|
||||||
|
let identity_facts: i64 = db.query_row(
|
||||||
|
"SELECT count(*) FROM facts WHERE predicate NOT IN ('psl','popularity')",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
assert_eq!(identity_facts, identity_before);
|
||||||
|
for table in ["reviews", "votes"] {
|
||||||
|
let count: i64 = db.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?;
|
||||||
|
assert_eq!(count, 0, "Web Graph unexpectedly populated {table}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = common::build(&db, root.path(), "first")?;
|
||||||
|
let second = common::build(&db, root.path(), "second")?;
|
||||||
|
assert_eq!(first.identity, second.identity);
|
||||||
|
assert_eq!(first.lookup("Facebook", 10)?.total_entities, 1);
|
||||||
|
assert_ne!(
|
||||||
|
first
|
||||||
|
.resolve_explained("Facebook", None, None, common::timestamp()?)?
|
||||||
|
.status,
|
||||||
|
ResolutionStatus::Resolved
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(first.popularity("googleapis.com", 10)?.total, 0);
|
||||||
|
|
||||||
|
let lookup = first.popularity("facebook.com", 10)?;
|
||||||
|
assert_eq!(lookup.total, 1);
|
||||||
|
let observation = &lookup.observations[0];
|
||||||
|
assert_eq!(observation.source, "common_crawl_web_graph");
|
||||||
|
assert_eq!(observation.target, "facebook.com");
|
||||||
|
assert_eq!(observation.value["harmonic_rank"], 2);
|
||||||
|
assert_eq!(observation.value["harmonic_value"], 3.213_156_2E7);
|
||||||
|
assert_eq!(observation.value["pagerank_rank"], 3);
|
||||||
|
assert_eq!(
|
||||||
|
observation.value["pagerank_value"],
|
||||||
|
0.012_273_178_013_351_222_f64
|
||||||
|
);
|
||||||
|
assert_eq!(observation.value["member_hosts"], 18_795);
|
||||||
|
assert!(observation.value.get("source_hosts").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
observation.provenance["source"]["license"],
|
||||||
|
"LicenseRef-Common-Crawl-Terms-of-Use"
|
||||||
|
);
|
||||||
|
let native_id: String = db.query_row(
|
||||||
|
"SELECT r.native_id FROM records r JOIN sources s ON s.id=r.source_id WHERE s.source='common_crawl_web_graph'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
assert_eq!(native_id, "row:2");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graph_import_requires_identity_candidates_and_exact_bound_scope() -> anyhow::Result<()> {
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let mut db = store::open(&root.path().join("store.sqlite"))?;
|
||||||
|
let mut manifest = common::manifest(
|
||||||
|
Source::CommonCrawlWebGraph,
|
||||||
|
Format::CommonCrawlDomainRanksTsv,
|
||||||
|
WEBGRAPH.as_bytes(),
|
||||||
|
)?;
|
||||||
|
manifest.scope = format!("candidate-domains:{}", "0".repeat(64));
|
||||||
|
let input = root.path().join("graph.tsv");
|
||||||
|
std::fs::write(&input, WEBGRAPH)?;
|
||||||
|
assert!(store::import(&mut db, &manifest, &input).is_err());
|
||||||
|
|
||||||
|
import_identity_candidates(&mut db, root.path())?;
|
||||||
|
assert!(store::import(&mut db, &manifest, &input).is_err());
|
||||||
|
manifest.scope = store::web_graph_selection(&db)?.scope;
|
||||||
|
store::import(&mut db, &manifest, &input)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_rank_parser_rejects_schema_and_value_drift() -> anyhow::Result<()> {
|
||||||
|
for bad in [
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\n",
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n\n",
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n0\t1\t1\t1\tcom.example\t1\n",
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n1\tNaN\t1\t1\tcom.example\t1\n",
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n1\t1\t1\t1\tcom..example\t1\n",
|
||||||
|
"#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n1\t1\t1\t1\tcom.example\t0\n",
|
||||||
|
] {
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let mut db = store::open(&root.path().join("store.sqlite"))?;
|
||||||
|
import_identity_candidates(&mut db, root.path())?;
|
||||||
|
assert!(
|
||||||
|
import_graph(&mut db, root.path(), bad.as_bytes()).is_err(),
|
||||||
|
"accepted malformed Web Graph input {bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_rank_source_url_is_exactly_allowlisted() -> anyhow::Result<()> {
|
||||||
|
let good = "https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz";
|
||||||
|
validate_source_url(Source::CommonCrawlWebGraph, good)?;
|
||||||
|
for bad in [
|
||||||
|
"http://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz",
|
||||||
|
"https://data.commoncrawl.org.evil.example/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz",
|
||||||
|
"https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/host/cc-main-2022-may-jun-aug-host-ranks.txt.gz",
|
||||||
|
"https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/other-domain-ranks.txt.gz",
|
||||||
|
"https://data.commoncrawl.org/projects/hyperlinkgraph/cc-main-2022-may-jun-aug/domain/cc-main-2022-may-jun-aug-domain-ranks.txt.gz?x=1",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
validate_source_url(Source::CommonCrawlWebGraph, bad).is_err(),
|
||||||
|
"accepted unreviewed Web Graph URL {bad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gzip_source_is_consumed_and_authenticated_end_to_end() -> anyhow::Result<()> {
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let compressed = common::gzip(WEBGRAPH.as_bytes())?;
|
||||||
|
let input = root.path().join("domain-ranks.txt.gz");
|
||||||
|
std::fs::write(&input, &compressed)?;
|
||||||
|
let mut db = store::open(&root.path().join("store.sqlite"))?;
|
||||||
|
import_identity_candidates(&mut db, root.path())?;
|
||||||
|
let mut manifest = graph_manifest(&db, &compressed)?;
|
||||||
|
manifest.compression = Compression::Gzip;
|
||||||
|
store::import(&mut db, &manifest, &input)?;
|
||||||
|
let registry = common::build(&db, root.path(), "gzip")?;
|
||||||
|
assert_eq!(registry.popularity("facebook.com", 10)?.total, 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "explicit resource benchmark"]
|
||||||
|
fn hundred_thousand_rows_import_within_engineering_budget() -> anyhow::Result<()> {
|
||||||
|
let mut input =
|
||||||
|
String::from("#harmonicc_pos\t#harmonicc_val\t#pr_pos\t#pr_val\t#host_rev\t#n_hosts\n");
|
||||||
|
for rank in 1..=100_000_u32 {
|
||||||
|
let reversed_domain = if rank == 50_000 {
|
||||||
|
"com.facebook".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("com.example-{rank}")
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
input,
|
||||||
|
"{rank}\t{}\t{rank}\t{}\t{reversed_domain}\t1",
|
||||||
|
100_001 - rank,
|
||||||
|
0.1
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let root = tempfile::tempdir()?;
|
||||||
|
let mut db = store::open(&root.path().join("store.sqlite"))?;
|
||||||
|
import_identity_candidates(&mut db, root.path())?;
|
||||||
|
let started = Instant::now();
|
||||||
|
import_graph(&mut db, root.path(), input.as_bytes())?;
|
||||||
|
assert!(
|
||||||
|
started.elapsed().as_secs_f64() < 10.0,
|
||||||
|
"100k Web Graph rows exceeded the 10-second engineering budget"
|
||||||
|
);
|
||||||
|
let retained: i64 = db.query_row(
|
||||||
|
"SELECT count(*) FROM facts WHERE predicate='popularity'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
assert_eq!(retained, 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,7 @@ source attribution and application-specific malware/content policy.
|
||||||
|
|
||||||
## Current contracts
|
## Current contracts
|
||||||
|
|
||||||
Code version 0.5.0 uses writer schema 5 and `argand.site-rules/v4`.
|
Code version 0.6.0 uses writer schema 5 and `argand.site-rules/v4`.
|
||||||
New compact `COMPLETE.json` files use `argand.site-registry/v3` and bind:
|
New compact `COMPLETE.json` files use `argand.site-registry/v3` and bind:
|
||||||
|
|
||||||
- authenticated `registry.sqlite` bytes;
|
- authenticated `registry.sqlite` bytes;
|
||||||
|
|
@ -101,8 +101,9 @@ revocation history. Never mutate a complete generation to migrate it. See
|
||||||
## Argand integration
|
## Argand integration
|
||||||
|
|
||||||
`UPSTREAM.json` records the original Argand extraction baseline and file hashes.
|
`UPSTREAM.json` records the original Argand extraction baseline and file hashes.
|
||||||
Argand pins signed v0.5.0 revision
|
Argand's prior integration pinned signed v0.5.0 revision
|
||||||
`3d3e08cdfd303df9fbd347a9bab2ba52ad575759`. The public beta uses Site Registry as
|
`3d3e08cdfd303df9fbd347a9bab2ba52ad575759`; the v0.6 downstream pin is recorded
|
||||||
|
by the Argand integration commit after this source release. The public beta uses Site Registry as
|
||||||
Navigate's authoritative auto-route catalog. Its native `navigation-catalog/v2`
|
Navigate's authoritative auto-route catalog. Its native `navigation-catalog/v2`
|
||||||
file is only a collection- and content-policy-bound serving projection compiled
|
file is only a collection- and content-policy-bound serving projection compiled
|
||||||
from one exact registry generation; it is not a second independently curated
|
from one exact registry generation; it is not a second independently curated
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
# Versioned formats
|
# Versioned formats
|
||||||
|
|
||||||
Version 0.5 uses writer schema 5 and `argand.site-rules/v4`. Schema identifiers
|
Version 0.6 uses writer schema 5 and `argand.site-rules/v4`. Schema identifiers
|
||||||
are independent from the crate version. Unknown schemas and rules fail closed.
|
are independent from the crate version. Unknown schemas and rules fail closed.
|
||||||
|
|
||||||
|
The Common Crawl domain-rank adapter is new in 0.6, so no older v4 store can
|
||||||
|
contain one of its source manifests. Its replacement scope is
|
||||||
|
`candidate-domains:<sha256>`, where the digest covers the canonical JSON encoding
|
||||||
|
of the sorted set of registrable domains derived from retained complete website
|
||||||
|
assertions. Retained superseded evidence may enlarge this conservative set but
|
||||||
|
cannot create a route or make a retired assertion active.
|
||||||
|
The importer authenticates and validates every graph row but persists only the
|
||||||
|
selected domains. A changed identity frontier therefore produces a new immutable
|
||||||
|
source identity instead of silently reusing a stale projection.
|
||||||
|
|
||||||
| Artifact | Current schema | Purpose |
|
| Artifact | Current schema | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Source manifest | `argand.site-source/v3` | Exact source object, integrity proof, lineage, parser bound and typed coverage |
|
| Source manifest | `argand.site-source/v3` | Exact source object, integrity proof, lineage, parser bound and typed coverage |
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ all been reviewed. A research entry below is not permission to ingest it.
|
||||||
| Source | Status | Decision and next gate |
|
| Source | Status | Decision and next gate |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| ROR | Admitted in 0.5 | The official CC0 schema 2.1 ZIP is streamed with exact Zenodo checksum evidence. Organization websites remain assertions; inactive/withdrawn edges are ineligible. GeoNames location lineage is explicit. |
|
| ROR | Admitted in 0.5 | The official CC0 schema 2.1 ZIP is streamed with exact Zenodo checksum evidence. Organization websites remain assertions; inactive/withdrawn edges are ineligible. GeoNames location lineage is explicit. |
|
||||||
|
| Common Crawl domain Web Graph ranks | Admitted after 0.5 as authority evidence | The official six-column domain-rank object is streamed from an exact allowlisted release URL under Common Crawl's Terms of Use. Harmonic centrality, PageRank and member-host count remain source separated. The source cannot create identities, official-site edges, reviews, or routes. Full-graph acquisition and production-catalog selection remain separate operational gates. |
|
||||||
| MusicBrainz | Next adapter; held | The [official download documentation](https://musicbrainz.org/doc/MusicBrainz_Database/Download) identifies the core `mbdump.tar.bz2` snapshot as CC0. The live replication/edit/statistics material with noncommercial terms is excluded. Admission still needs a current core snapshot/checksum canary and a bounded relational-table adapter for documented [URL relationships](https://musicbrainz.org/doc/Style/Relationships/URLs). |
|
| MusicBrainz | Next adapter; held | The [official download documentation](https://musicbrainz.org/doc/MusicBrainz_Database/Download) identifies the core `mbdump.tar.bz2` snapshot as CC0. The live replication/edit/statistics material with noncommercial terms is excluded. Admission still needs a current core snapshot/checksum canary and a bounded relational-table adapter for documented [URL relationships](https://musicbrainz.org/doc/Style/Relationships/URLs). |
|
||||||
| GND | Research hold | The [DNB open-data distribution](https://data.dnb.de/opendata/) must be checked at implementation time for the exact file license, current JSON-LD/RDF predicates, checksum and useful homepage coverage. Stop the adapter if explicit homepage coverage does not justify it. |
|
| GND | Research hold | The [DNB open-data distribution](https://data.dnb.de/opendata/) must be checked at implementation time for the exact file license, current JSON-LD/RDF predicates, checksum and useful homepage coverage. Stop the adapter if explicit homepage coverage does not justify it. |
|
||||||
| ORCID public data | Research hold | Its self-declared links need an individuals-only privacy, impersonation and volatility policy in addition to the [public-file terms](https://info.orcid.org/public-data-file-use-policy/). It could never auto-approve a route. |
|
| ORCID public data | Research hold | Its self-declared links need an individuals-only privacy, impersonation and volatility policy in addition to the [public-file terms](https://info.orcid.org/public-data-file-use-policy/). It could never auto-approve a route. |
|
||||||
| OpenAlex institutions | Correlated-source hold | Institution metadata can inherit ROR. Any future use must declare ROR upstream and cannot count as independent website corroboration. See the [institution source documentation](https://help.openalex.org/data/institutions/). |
|
| OpenAlex institutions | Correlated-source hold | Institution metadata can inherit ROR. Any future use must declare ROR upstream and cannot count as independent website corroboration. See the [institution source documentation](https://help.openalex.org/data/institutions/). |
|
||||||
| OpenStreetMap | License-architecture hold | No ingestion until an ODbL-compatible attribution, database-right and redistribution design is accepted. See the [OSMF license FAQ](https://osmfoundation.org/wiki/Licence_and_Legal_FAQ). |
|
| OpenStreetMap | License-architecture hold | No ingestion until an ODbL-compatible attribution, database-right and redistribution design is accepted. See the [OSMF license FAQ](https://osmfoundation.org/wiki/Licence_and_Legal_FAQ). |
|
||||||
| Government/corporate registries | Jurisdiction hold | Review one jurisdiction and exact field at a time. Stable identifiers may support crosswalks; the registry cannot infer a website absent an authoritative field. |
|
| Government/corporate registries | Jurisdiction hold | Review one jurisdiction and exact field at a time. Stable identifiers may support crosswalks; the registry cannot infer a website absent an authoritative field. |
|
||||||
| DNS, RDAP, certificate transparency, package registries, web crawl data | Observation-only research | Exact commercial reuse terms and retention rules must be approved first. These sources describe current infrastructure and cannot establish entity ownership alone. |
|
| DNS, RDAP, certificate transparency, package registries, other web crawl data | Observation-only research | Exact commercial reuse terms and retention rules must be approved first. These sources describe current infrastructure and cannot establish entity ownership alone. |
|
||||||
| Open Library and unresolved-rights sources | Excluded | Keep excluded until the underlying data rights and redistribution obligations are clear enough for commercial reuse. |
|
| Open Library and unresolved-rights sources | Excluded | Keep excluded until the underlying data rights and redistribution obligations are clear enough for commercial reuse. |
|
||||||
|
|
||||||
Cloudflare Radar, default Tranco, Cisco Umbrella, arbitrary mirrors, and any
|
Cloudflare Radar, default Tranco, Cisco Umbrella, arbitrary mirrors, and any
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,27 @@
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
|
# Version 0.6.0 Web Graph and updater validation, 2026-09-22
|
||||||
|
|
||||||
|
The evaluation contract was written before implementation. The new Common Crawl
|
||||||
|
domain-rank integration passed five default integration tests; its sixth test is
|
||||||
|
an explicit resource benchmark. The benchmark parsed 100,000 valid provider-shaped
|
||||||
|
rows, retained exactly one identity-matched domain, and completed in 0.52 seconds
|
||||||
|
of test time. The enclosing warm Cargo process used 79,944 KiB peak RSS, wrote
|
||||||
|
9,136 filesystem blocks, and used no swap on the development machine. These are
|
||||||
|
engineering bounds, not a full 2 GiB provider-object throughput claim.
|
||||||
|
|
||||||
|
`scripts/check.sh` passed after the v0.6 version and Rustls lockfile updates. It
|
||||||
|
covered formatting, offline all-target checks, strict Clippy, all Rust and CLI
|
||||||
|
tests, documentation, Python release-package tests, and native/Python consumer
|
||||||
|
parity. The focused graph suite proves full-stream schema/value validation,
|
||||||
|
source-line provenance, gzip authentication, exact URL allowlisting, deterministic
|
||||||
|
builds, compact candidate-domain selection, and zero route authorization from
|
||||||
|
rank evidence.
|
||||||
|
|
||||||
|
The networked `cargo audit --deny warnings` gate initially detected
|
||||||
|
RUSTSEC-2026-0285 in Rustls 0.23.43. The lockfile was updated to Rustls 0.23.45;
|
||||||
|
the repeated audit passed with no findings.
|
||||||
|
|
||||||
## Version 0.5.0 release and security validation, 2026-09-13
|
## Version 0.5.0 release and security validation, 2026-09-13
|
||||||
|
|
||||||
Implementation commit: `557ba7cd6982b02754d34fb99cba5a116f78f153`, signed by
|
Implementation commit: `557ba7cd6982b02754d34fb99cba5a116f78f153`, signed by
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue