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
8 changes: 8 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,16 @@ jobs:
# short-lived crates.io token.
id-token: write
steps:
# The owid-rust submodule holds the OWID source that ci/copy-owid-source.ps1
# compiles into fodid. The copy is ignored by git and listed in the fodid
# manifest's include, so cargo publish packages it with the crate.
- uses: actions/checkout@v4
with:
submodules: true
- uses: dtolnay/rust-toolchain@stable
- name: Copy the OWID source into fodid
shell: pwsh
run: ./ci/copy-owid-source.ps1
- name: Authenticate to crates.io (Trusted Publishing)
uses: rust-lang/crates-io-auth-action@v1
id: auth
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ jobs:
with:
key: ${{ matrix.os }}

# 6a. The OWID source the fodid crate compiles in. ci/copy-owid-source.ps1
# copies it from the owid-rust submodule (checked out in step 1) into
# fodid/src/owid, which git ignores, so every fmt, build, test, lint
# and doc step below needs the copy in place first.
- name: Copy the OWID source into fodid
shell: pwsh
run: ./ci/copy-owid-source.ps1

# 7. The verification gate. fmt is checked first because it is the cheapest
# and most common failure. clippy and doc inherit RUSTFLAGS/RUSTDOCFLAGS
# so any warning fails the build.
Expand Down Expand Up @@ -241,8 +249,12 @@ jobs:
# Cross-compile to wasm is host-neutral; pin to a current LTS for consistency.
runs-on: ubuntu-24.04
steps:
# The owid-rust submodule holds the OWID source that the fodid crate
# compiles in, so it is needed here even though the C/C++ sources are not.
- name: Checkout workspace
uses: actions/checkout@v4
with:
submodules: true

- name: Install Rust toolchain with wasm target
uses: dtolnay/rust-toolchain@stable
Expand All @@ -257,6 +269,12 @@ jobs:
# different target triple and feature set.
key: wasm32-wasip1

# fodid compiles the OWID source that ci/copy-owid-source.ps1 places under
# fodid/src/owid (ignored by git), so the copy runs before any build.
- name: Copy the OWID source into fodid
shell: pwsh
run: ./ci/copy-owid-source.ps1

# Each crate is built standalone rather than in one cargo invocation so
# feature unification across the set cannot pull reqwest in through a
# sibling and hide a per-crate wasm break. --all-targets is deliberately
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ Thumbs.db
.codeium/
.gemini/
.opencode/

# The OWID source that ci/copy-owid-source.ps1 copies into the fodid crate
# before a build. The owid-rust submodule is the only copy that belongs in
# the repository.
/fodid/src/owid/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "owid-rust"]
path = owid-rust
url = https://github.com/51Degrees/owid-rust.git
21 changes: 17 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,16 @@ build without any submodule checkout.

## Building and testing

From the repository root:
Once per clone, and again whenever the `owid-rust` submodule moves, place the
OWID source that `fodid` compiles in (see "Releasing to crates.io" below for
why it is carried this way):

```
git submodule update --init
pwsh ./ci/copy-owid-source.ps1
```

Then, from the repository root:

```
cargo build --workspace --all-targets
Expand Down Expand Up @@ -83,9 +92,13 @@ exchanges the run's GitHub OIDC identity for a short-lived token, so no API
token is stored. Each crate has this repository and `publish.yml` registered as
a trusted publisher (one-time setup in `ci/setup-trusted-publishing.sh`).

The `owid` dependency the `fodid` crates build on is consumed from crates.io
(crates.io forbids git dependencies); its source lives in the SWAN community
repository.
The OWID library the `fodid` crates build on is not a crates.io dependency.
Its source is compiled into `fodid` as a private module from the `owid-rust`
submodule (https://github.com/51Degrees/owid-rust, a fork that follows the
SWAN community repository) by `ci/copy-owid-source.ps1`, which every workflow
runs before building, so no OWID crate has to exist on any registry. The
copied directory `fodid/src/owid` is ignored by git and listed in the `fodid`
manifest's `include`, so `cargo publish` packages it.

## CI gates

Expand Down
19 changes: 5 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ repository = "https://github.com/51Degrees/rust"
homepage = "https://51degrees.com"

[workspace.dependencies]
# The OWID envelope library every 51Did builds on. Consumed from crates.io so
# the fodid crates are publishable (crates.io forbids git dependencies). The
# source lives in the SWAN community repository
# (https://github.com/SWAN-community/owid-rust).
owid = "1.0.0"

# Shared third-party dependencies pinned once here and inherited by members
# through `<dep>.workspace = true`. The reqwest blocking client used by the
# data-update and usage-sharing background threads stays pinned in those crates
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).a

## Building

The `fodid` crate compiles the OWID library in from the `owid-rust` submodule,
so place that source once per clone before building:

```sh
git submodule update --init
pwsh ./ci/copy-owid-source.ps1
```

```sh
cargo build --workspace
cargo test --workspace --all-features
Expand Down Expand Up @@ -296,10 +304,13 @@ export EXAMPLE_LANG="rust"
dotnet test --filter TestCategory=Contract
```

The `fodid` crate depends on the
[`owid`](https://github.com/SWAN-community/owid-rust) crate (the OWID envelope
library a 51Did is built on), consumed as a git dependency. A network
connection is required the first time the dependency is fetched.
The `fodid` crate compiles the OWID envelope library (the library a 51Did is
built on) into itself from the `owid-rust` submodule
(https://github.com/51Degrees/owid-rust), so no OWID crate has to exist on
any registry. After cloning, run `git submodule update --init` and then
`pwsh ./ci/copy-owid-source.ps1` (PowerShell 7, on any platform) to place the
source under `fodid/src/owid`, which git ignores. Run the script again after
moving the submodule to another commit.

## Editor and IDE setup

Expand Down
103 changes: 103 additions & 0 deletions ci/copy-owid-source.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
param (
# The root of the rust working copy. Defaults to the parent of this
# script, which is right for a developer checkout, and can be passed
# explicitly by a caller that runs from another directory.
[string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)
)
$ErrorActionPreference = "Stop"

# Copies the OWID source into the fodid crate as the private module
# fodid::owid, so that the crate published to crates.io carries the OWID code
# itself and no OWID crate has to exist on any registry.
#
# The dependency cannot come from a package registry. The OWID library is
# maintained in the SWAN community and is moving to Prebid, and 51Degrees
# does not publish or own an owid crate, so fodid must not depend on one.
# Compiling the source in, as the .NET package compiles the owid-dotnet
# source into FiftyOne.Did.dll and the Python package copies owid-python in
# as a private module, keeps the 51Did crates publishable on their own.
#
# Nothing is fetched over the network, because owid-rust is a submodule and
# CI clones with submodules. Nothing is written back to the repository
# either, as the copy is ignored by git and main keeps the submodule as the
# single source of the OWID code. The script can be run as often as needed
# and always leaves the same result for the same submodule commit.
#
# Three mechanical changes are made on the way in, because the files were
# written as a crate root and are compiled here as a module. lib.rs becomes
# mod.rs, every absolute path that starts with crate:: is prefixed with the
# module name so that it starts with crate::owid:: instead, and the examples
# in the documentation comments reach the library through fodid:: rather
# than owid::, so they compile and run as doc tests of this crate. Nothing
# else is altered.

$owidRepo = Join-Path $RepoRoot "owid-rust"
$owidSource = Join-Path $owidRepo "src"
$target = Join-Path $RepoRoot "fodid/src/owid"

if (-not (Test-Path (Join-Path $owidSource "lib.rs"))) {
throw "OWID source not found at '$owidSource'. Run " +
"'git submodule update --init --recursive' first, or clone with " +
"--recurse-submodules."
}

# The commit the copy was taken from, so the notice can say exactly which
# version of the OWID source is inside the crate.
$commit = (git -C $owidRepo rev-parse HEAD 2>$null)
if (-not $commit) {
$commit = (git -C $RepoRoot rev-parse "HEAD:owid-rust" 2>$null)
}
if (-not $commit) {
throw "Could not determine the owid-rust commit to record in the notice."
}

if (Test-Path $target) {
Remove-Item -Path $target -Recurse -Force
}
$null = New-Item -ItemType Directory -Path $target -Force

foreach ($file in Get-ChildItem -Path $owidSource -Filter "*.rs" -File) {
$name = if ($file.Name -eq "lib.rs") { "mod.rs" } else { $file.Name }
$lines = foreach ($line in Get-Content -Path $file.FullName) {
if ($line -cmatch '^\s*//[/!]') {
# A documentation line. The examples in the library documentation
# name the crate as owid, and here the same items are reached
# through fodid, so the examples run against the re-exported
# surface. A path that already starts with crate:: is left for
# the rewrite below.
$line = $line -creplace '(?<![\w:])owid::', 'fodid::'
}
$line.Replace("crate::", "crate::owid::")
}
Set-Content -Path (Join-Path $target $name) -NoNewline -Encoding utf8 `
-Value (($lines -join "`n") + "`n")
}
Copy-Item -Path (Join-Path $owidRepo "LICENSE") `
-Destination (Join-Path $target "LICENSE") -Force

$notice = @"
The Rust source files in this directory are the OWID (Open Web Id) library.
They are copied into the fodid crate at build time and are not part of the
51Degrees source, so they keep their own licence, which is the Apache License
2.0 in the LICENSE file beside this notice, and not the EUPL 1.2 that covers
the rest of the crate.

Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com)

Taken from the 51Degrees fork of the OWID project,
https://github.com/51Degrees/owid-rust, at commit
$commit
which follows https://github.com/SWAN-community/owid-rust.

The files are compiled as the private module owid inside the fodid crate, so
that publishing fodid never claims the crate name "owid" on any registry and
no OWID crate has to exist for fodid to build. On the way in lib.rs was
renamed mod.rs, every path starting with crate:: was changed to start with
crate::owid::, and the examples in the documentation comments reach the
library through fodid:: rather than owid::. Nothing else was altered. Use the
OWID library from the fork itself rather than from here.
"@
Set-Content -Path (Join-Path $target "NOTICE") -NoNewline -Encoding utf8 `
-Value (($notice -replace "`r?`n", "`n") + "`n")

Write-Output "Copied OWID source at $commit into '$target'"
8 changes: 5 additions & 3 deletions ci/publish-crates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
# After publishing a crate the script waits for the new version to appear on the
# index so the next, dependent crate resolves it.
#
# `fodid` and `fiftyone-fodid-cloud` depend on `owid`, which is consumed from
# crates.io (the workspace `owid` dependency is a published version, not git), so
# they are publishable and included below in dependency order.
# `fodid` and `fiftyone-fodid-cloud` need no OWID crate from any registry. The
# OWID source is compiled into `fodid` from the owid-rust submodule by
# ci/copy-owid-source.ps1, which the publish workflow runs before this script,
# and the `fodid` manifest lists the copied directory in its `include` so that
# `cargo publish` packages it even though git ignores it.
set -euo pipefail

# Publishing needs a crates.io token. When it is absent (for example a normal
Expand Down
7 changes: 4 additions & 3 deletions fodid-cloud/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ once_cell.workspace = true
reqwest-client = ["fiftyone-cloud-request-engine/reqwest-client"]

[dev-dependencies]
# Used by the integration test to mint a real signed 51Did envelope, so the
# parsed-identifier path is exercised end-to-end.
owid.workspace = true
# The tests create a real signed 51Did envelope to stand in for the cloud,
# which needs the OWID creator types fodid exposes under its creator
# feature.
fodid = { version = "4.5.2", path = "../fodid", features = ["creator"] }

[package.metadata.docs.rs]
# Build the documentation on docs.rs with every feature enabled, so the
Expand Down
40 changes: 30 additions & 10 deletions fodid-cloud/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ const WRONG_TYPE_MESSAGE: &str =
/// accessor returns an
/// [`AspectPropertyValue<T>`] so the caller can tell "the cloud issued an
/// identifier" from "no identifier was available" (and, for the parsed form,
/// from "the envelope could not be decoded") without losing the explanation.
/// from "the value could not be read as a 51Did") without losing the
/// explanation.
pub trait FodIdData: AspectData {
/// The globally-scoped 51Did as the raw base64 OWID envelope string, as the
/// cloud sent it. Property name [`ID_PROB_GLOBAL_PROPERTY`].
Expand All @@ -168,9 +169,9 @@ pub trait FodIdData: AspectData {
/// The globally-scoped 51Did parsed into a [`FodId`], unpacking the OWID
/// envelope and its payload (flags, license id, hash).
///
/// Returns a no-value if the identifier was absent, or if the base64
/// envelope could not be decoded (the no-value message then carries the
/// decode error).
/// Returns a no-value if the identifier was absent, or if the value could
/// not be read as a 51Did (the no-value message then carries the reason
/// the `fodid` reader gave). Reading does not verify the signature.
fn id_prob_global_fod_id(&self) -> AspectPropertyValue<FodId>;

/// The license-scoped 51Did parsed into a [`FodId`]. Behaves like
Expand Down Expand Up @@ -298,16 +299,19 @@ impl FodIdDataBase {

/// Read a base64 identifier property and parse it into a [`FodId`].
///
/// An absent property is a no-value with the standard absent message; a
/// present value that does not decode as a 51Did envelope is a no-value
/// carrying the decode error, so a malformed identifier never panics or
/// fails the whole result.
/// An absent property is a no-value with the standard absent message. A
/// present value that does not read as a 51Did is a no-value whose
/// message carries the reason the `fodid` reader gave (the OWID status,
/// or one of the two 51Did payload statuses), so a malformed identifier
/// never panics or fails the whole result. Reading does not verify the
/// signature, so a parsed value is not necessarily cryptographically
/// valid.
fn parsed_property(&self, name: &str) -> AspectPropertyValue<FodId> {
match self.string_property(name).into_value() {
Ok(base64) => match FodId::from_base64(&base64) {
Ok(fod_id) => AspectPropertyValue::new(fod_id),
Err(error) => AspectPropertyValue::no_value(format!(
"The 51Did value could not be decoded as an OWID envelope: {error}"
"The 51Did value could not be read: {error}"
)),
},
// The string accessor already supplied the absent/wrong-type
Expand Down Expand Up @@ -464,7 +468,23 @@ mod tests {
assert!(!parsed.has_value());
assert!(parsed
.no_value_message()
.is_some_and(|m| m.contains("could not be decoded")));
.is_some_and(|m| m.contains("could not be read") && m.contains("InvalidBase64")));
}

#[test]
fn short_payload_parses_to_a_no_value_naming_the_51did_status() {
// A well formed OWID whose payload is shorter than the 51Did header
// is not a 51Did. The no-value message names the 51Did status so the
// reason is not lost behind a generic decode failure.
let creator = fodid::Creator::new("51degrees.com", fodid::Crypto::new()).unwrap();
let envelope = creator.create(vec![0u8; 3]).unwrap().as_base64().unwrap();
let data = FodIdDataBase::new().set("IdProbGlobal", envelope.clone());
assert_eq!(data.id_prob_global().value().unwrap(), &envelope);
let parsed = data.id_prob_global_fod_id();
assert!(!parsed.has_value());
assert!(parsed
.no_value_message()
.is_some_and(|m| m.contains("PayloadTooShort")));
}

#[test]
Expand Down
Loading
Loading