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
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "Archeion"
uuid = "4f7774e9-efb7-42d6-b735-122dcc7ea7b8"
version = "0.4.2"
version = "0.4.3"
authors = ["sota shimozono <shimozono-sota631@g.ecc.u-tokyo.ac.jp>"]

[deps]
Expand Down
12 changes: 7 additions & 5 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,16 @@ key file read_sha256 result_sha256 observation completed_at
when kept, at `repro/blobs/<first 32 of the file's SHA-256>`. Paths use 32 hex (128 bits) to stay
within R3; the files keep the full digests, and a checker compares full digests.
- An observation's `binding` is how far that process's loaded code was checked against its
snapshot: `loaded-matches-disk` (every loaded source of the checked roots matched it),
`loaded-differs-from-disk`, or `unverified` (with `binding_reasons`). An observation is a disk
state; only its binding speaks about the code that ran, and `unverified` claims nothing.
snapshot: `loaded-differs-from-disk` (a loaded package's sources are not what the snapshot
holds) or `unverified` (with `binding_reasons`). An observation is a disk state, and
`unverified` claims nothing about the code that ran. A `loaded-matches-disk`, which earlier
data stores wrote, is read and counted as `unverified`: a match cannot be shown from inside the
computing process, since code defined outside a package leaves no trace to check it against.
- A depositor refuses a row whose `read_sha256` differs from its `result_sha256` unless told to let
it in, which is then recorded as `allow_mismatch = true`. A validator requires the summary to match
the table, every named token to be held or listed as missing, every binding to be one of the three
values, and every snapshot to hash to its id; it warns on rows that read other bytes and on
missing observations.
values, and every snapshot to hash to its id; it warns on rows that read other bytes, on
missing observations, and on every `loaded-matches-disk`.
- When `provenance.toml` exists, `repro/observations/`, `repro/sources/` and `repro/blobs/` are its
own.

Expand Down
16 changes: 15 additions & 1 deletion src/build.jl
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ end

# ── build ─────────────────────────────────────────────────────────────────────────────────────

# What of a revision the site serves: everything a reader opens, not the evidence behind it. The
# point table and `repro/` (observations, source snapshots and contents) stay in the repository:
# a table of 80_000 points is 24 MiB per revision, and a site that copied it would carry it once
# per revision ever deposited. The summary, `provenance.toml`, is served and links nowhere.
const SITE_SKIP = ("provenance", "repro")

function copy_revision(src, dest)
mkpath(dest)
for name in readdir(src)
name in SITE_SKIP && isdir(joinpath(src, name)) && continue
cp(joinpath(src, name), joinpath(dest, name))
end
end

function build(root, out=joinpath(root, "_site"); name=basename(abspath(root)))
r, _ = validate(root)
isempty(r.errors) || error(
Expand All @@ -336,7 +350,7 @@ function build(root, out=joinpath(root, "_site"); name=basename(abspath(root)))
dest = joinpath(out, rec.rel)
mkpath(joinpath(dest, "revisions"))
for rev in rec.revs
cp(rev.dir, joinpath(dest, "revisions", rev.name))
copy_revision(rev.dir, joinpath(dest, "revisions", rev.name))
end
write(joinpath(dest, "index.html"), record_page(name, projects, rec))
end
Expand Down
46 changes: 31 additions & 15 deletions src/deposit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,23 @@ function readme(e)
return String(take!(io))
end

# A face is a directory; `Pinax.render` and `Pinax.report` return the file they wrote in it
# (`index.html`, `agent.json`), so a file stands for its directory.
face_dir(path) = isfile(path) ? dirname(path) : path

# Remove a revision being built. A failure here must not replace the failure that got us here:
# the caller is about to rethrow what actually went wrong.
function discard!(dir)
try
rm(dir; recursive=true, force=true)
catch e
@warn "could not remove $dir" exception = e
end
return nothing
end

function copy_tree(src, dest)
isdir(src) || error("$src is not a directory")
for (dir, _, files) in walkdir(src), f in files
f in SKIP && continue
target = joinpath(dest, relpath(joinpath(dir, f), src))
Expand Down Expand Up @@ -269,23 +285,23 @@ function deposit(
incoming = joinpath(reg, "_incoming", rev)
ispath(incoming) && error("$incoming exists")
mkpath(incoming)
copy_tree(gallery, joinpath(incoming, "gallery"))
copy_tree(agent, joinpath(incoming, "agent"))
for (dest, src) in repro
mkpath(dirname(joinpath(incoming, "repro", dest)))
cp(src, joinpath(incoming, "repro", dest))
end
open(io -> TOML.print(io, entry; sorted=true), joinpath(incoming, "entry.toml"), "w")
write(joinpath(incoming, "README.md"), readme(entry))
if provenance !== nothing
try
write_provenance!(incoming; provenance...)
catch
rm(incoming; recursive=true, force=true) # nothing half-written is left behind
rethrow()
try
copy_tree(face_dir(gallery), joinpath(incoming, "gallery"))
copy_tree(face_dir(agent), joinpath(incoming, "agent"))
for (dest, src) in repro
mkpath(dirname(joinpath(incoming, "repro", dest)))
cp(src, joinpath(incoming, "repro", dest))
end
open(
io -> TOML.print(io, entry; sorted=true), joinpath(incoming, "entry.toml"), "w"
)
write(joinpath(incoming, "README.md"), readme(entry))
provenance === nothing || write_provenance!(incoming; provenance...)
write_sums(incoming) # last: its presence means "complete"
catch e
discard!(incoming) # nothing half-written is left behind
rethrow(e)
end
write_sums(incoming) # last: its presence means "complete"

final = joinpath(revroot, rev)
mkpath(revroot)
Expand Down
15 changes: 13 additions & 2 deletions src/provenance.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const POINT_COLUMNS = (
"key", "file", "read_sha256", "result_sha256", "observation", "completed_at"
)
const BINDINGS = Set(["loaded-matches-disk", "loaded-differs-from-disk", "unverified"])

# A match cannot be shown from inside the computing process (code defined in a script, a closure,
# or a method added to Base leaves no trace to check), and DataVault 0.8.6 no longer writes one. An
# earlier observation's `loaded-matches-disk` is therefore counted as what it can support.
_counted(binding) = binding == "loaded-matches-disk" ? "unverified" : binding
const POINTS_FILE = "provenance/points.tsv"

# Content-addressed names are cut to 32 hex (128 bits) in paths, so they stay within R3 (64 per
Expand Down Expand Up @@ -112,7 +117,7 @@ function write_provenance!(
push!(present, t)
_copy_if_absent(src, joinpath(revdir, "repro", "observations", "$t.toml"))
obs = TOML.parsefile(src)
binding_of_token[t] = get(obs, "binding", "unknown")
binding_of_token[t] = _counted(get(obs, "binding", "unknown"))
if source_contents # the Project/Manifest the process ran with, when it stored them
for sha in values(get(obs, "environment", Dict()))
blob = joinpath(sources_dir, "blobs", string(sha))
Expand Down Expand Up @@ -239,7 +244,13 @@ function check_provenance(r, revdir)
obs = load(r, obs_path)
obs === nothing && continue
binding = get(obs, "binding", nothing)
binding_of_token[t] = string(binding)
binding == "loaded-matches-disk" && warn!(
r,
obs_path,
"`loaded-matches-disk` cannot rule out code defined outside a package (a script, a " *
"closure, a method added to Base); it is counted as `unverified`",
)
binding_of_token[t] = _counted(string(binding))
binding in BINDINGS || err!(
r,
obs_path,
Expand Down
64 changes: 64 additions & 0 deletions test/test_deposit.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
# deposit: revisions go in through a binding, and a revision that does not validate never lands.

@testset "deposit: `repro` puts named files under repro/" begin
with_git_fixture() do root, binding, src
script = joinpath(mktempdir(), "run.jl")
write(script, "# the script that made it\n")
res = deposit(
binding;
src...,
doc=DOC,
source_repo=root,
push=false,
repro=Dict("scripts/run.jl" => script),
)
@test read(joinpath(res.dir, "repro", "scripts", "run.jl"), String) ==
"# the script that made it\n"
@test isempty(first(Archeion.validate(root)).errors)
end
end

@testset "deposit: a cleanup that fails keeps the failure it was cleaning up after" begin
parent = mktempdir()
dir = joinpath(parent, "rev")
mkpath(dir)
write(joinpath(dir, "entry.toml"), "x")
chmod(parent, 0o500) # the entry cannot be unlinked
try
@test_logs (:warn, r"could not remove") Archeion.discard!(dir)
@test isdir(dir) # and the caller still rethrows its own
finally
chmod(parent, 0o700)
rm(parent; recursive=true, force=true)
end
end

@testset "deposit: the file a render returns stands for its directory" begin
with_git_fixture() do root, binding, src
res = deposit(
binding;
gallery=joinpath(src.gallery, "index.html"),
agent=joinpath(src.agent, "agent.json"),
doc=DOC,
source_repo=root,
push=false,
)
@test isfile(joinpath(res.dir, "gallery", "index.html"))
@test isfile(joinpath(res.dir, "agent", "agent.json"))
@test isempty(first(Archeion.validate(root)).errors)
end
with_git_fixture() do root, binding, src
e = attempt(
() -> deposit(
binding;
gallery=joinpath(root, "no-such-dir"),
agent=src.agent,
doc=DOC,
source_repo=root,
push=false,
),
)
@test e isa ErrorException && occursin("is not a directory", e.msg)
incoming = joinpath(root, "_incoming")
@test commits(root) == 1 && (!isdir(incoming) || isempty(readdir(incoming)))
end
end

@testset "deposit" begin
with_git_fixture() do root, binding, src
res = deposit(binding; src..., doc=DOC, source_repo=root, push=false)
Expand Down
35 changes: 30 additions & 5 deletions test/test_provenance.jl
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ using DataVault
# contents kept), and one observation of it with the given binding.
const OBS = "obs1-20260922T000000Z-1a2b-0123456789abcdef"

function synthetic_store(; binding="loaded-matches-disk")
function synthetic_store(; binding="unverified", version=1)
dir = mktempdir()
blob = "module P\nend\n"
bsha = bytes2hex(sha256(blob))
Expand All @@ -24,7 +24,8 @@ function synthetic_store(; binding="loaded-matches-disk")
mkpath(observations)
write(
joinpath(observations, "$token.toml"),
"token = \"$token\"\nsource = \"$id\"\nbinding = \"$binding\"\nbinding_reasons = []\n",
"observation_version = $version\ntoken = \"$token\"\nsource = \"$id\"\n" *
"binding = \"$binding\"\nbinding_reasons = []\n",
)
return (; dir, observations, sources, token, id, bsha)
end
Expand Down Expand Up @@ -92,7 +93,7 @@ rewrite!(path, f) = write(path, f(read(path, String)))
"read_differs_from_result" => 0,
"result_unknown" => 1,
)
@test p["bindings"] == Dict("loaded-matches-disk" => 2, "unknown" => 1)
@test p["bindings"] == Dict("unverified" => 2, "unknown" => 1)
@test p["observations"] == [t] && isempty(p["missing_observations"])
rows = readlines(joinpath(res.dir, "provenance", "points.tsv"))
@test first.(split.(rows[2:end], '\t')) == ["k1", "k2", "k3"]
Expand All @@ -108,9 +109,13 @@ rewrite!(path, f) = write(path, f(read(path, String)))
Archeion.build(root, site)
rec = relpath(dirname(dirname(res.dir)), root)
page = read(joinpath(site, rec, "index.html"), String)
served = joinpath(site, relpath(res.dir, root))
@test isfile(joinpath(served, "provenance.toml")) &&
isdir(joinpath(served, "gallery"))
@test !ispath(joinpath(served, "provenance")) && !ispath(joinpath(served, "repro"))
@test occursin("3 points: 2 read as recorded", page) &&
occursin("1 unrecorded", page)
@test occursin("code loaded-matches-disk 2, unknown 1", page)
@test occursin("code unknown 1, unverified 2", page)
rm(dirname(site); recursive=true)
end
end
Expand Down Expand Up @@ -151,6 +156,24 @@ end
end
end

@testset "provenance: a match is not taken at its word" begin
store = synthetic_store(; binding="loaded-matches-disk")
deposited([point("k1", store.token)]; store) do root, res
r, _ = Archeion.validate(root)
@test isempty(r.errors)
@test mentions(r.warnings, "it is counted as `unverified`")
p = TOML.parsefile(joinpath(res.dir, "provenance.toml"))
@test p["bindings"] == Dict("unverified" => 1) # never counted as a match
end
store = synthetic_store(; binding="loaded-differs-from-disk")
deposited([point("k1", store.token)]; store) do root, res
r, _ = Archeion.validate(root)
@test isempty(r.warnings)
@test TOML.parsefile(joinpath(res.dir, "provenance.toml"))["bindings"] ==
Dict("loaded-differs-from-disk" => 1)
end
end

@testset "provenance: a name that is not a token never becomes a path" begin
deposited([point("k1", "../../escape")]) do root, res
@test res isa ErrorException && occursin("not an observation token", res.msg)
Expand Down Expand Up @@ -182,7 +205,9 @@ end

v = broken() do d
obs = joinpath(d, "repro", "observations", "$t.toml")
rewrite!(obs, s -> replace(s, "loaded-matches-disk" => "trust-me"))
rewrite!(
obs, s -> replace(s, "binding = \"unverified\"" => "binding = \"trust-me\"")
)
end
@test mentions(v.errors, "\"trust-me\" is not one of")
@test mentions(v.errors, "`bindings` does not match")
Expand Down
Loading