| index 7718d9350..512a1c360 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -941,8 +941,21 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| if work == nil { |
| return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session) |
| } |
| - // The files on disk must be the landing's before anything is published. |
| - if err := work.With(ctx, in.Identity, func() error { return m.verifyFiles(o.Host, v) }); err != nil { |
| + // The files on disk must be the landing's before anything is published, |
| + // and their content is recorded as this generation's install record |
| + // (fix round 3): the digest the refresh, and any later repair, compare. |
| + var digest string |
| + if err := work.With(ctx, in.Identity, func() error { |
| + if err := m.verifyFiles(o.Host, v); err != nil { |
| + return err |
| + } |
| + var err error |
| + digest, err = m.filesDigest(o.Host) |
| + return err |
| + }); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if err := m.recordFilesDigest(o.Host, v.op, h.Generation, digest); err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| if err := pub.Sync(ctx); err != nil { |
| @@ -969,7 +982,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| } |
| receipt := ClaimPublicationReceipt{MarkerVersion: current.ID, Root: root} |
| - if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil { |
| + if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity, digest); err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| m.record(o.Host, h.Session, receipt) |
| @@ -994,7 +1007,7 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable |
| } |
| receipt := ClaimPublicationReceipt{MarkerVersion: version, Root: root} |
| - if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil { |
| + if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity, digest); err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| m.record(o.Host, h.Session, receipt) |
| @@ -1044,7 +1057,7 @@ func (m *movesProbeOwners) verifyFiles(host string, v probeLanding) error { |
| // published. Task 4's start check is unchanged: the next admission's cold |
| // predecessor is this generation. <dbPath>.gen needs no refresh: every cold |
| // restore rewrites it to the admitted generation (H11). |
| -func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v probeLanding, work hostwrite.Work, id hostwrite.Identity) error { |
| +func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v probeLanding, work hostwrite.Work, id hostwrite.Identity, digest string) error { |
| h := v.holder |
| marker, err := json.Marshal(moves.FilesGen{Op: h.Generation, Incarnation: h.Incarnation}) |
| if err != nil { |
| @@ -1052,7 +1065,7 @@ func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v p |
| } |
| dir, _ := m.FilesDir(host) |
| return work.With(ctx, id, func() error { |
| - if err := m.verifyFiles(host, v); err != nil { |
| + if err := m.verifyFilesContent(host, v, digest); err != nil { |
| return err |
| } |
| if err := syncPath(dir); err != nil { |
| @@ -1062,6 +1075,84 @@ func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v p |
| }) |
| } |
| |
| +// filesPlainStep is the op journal step a published generation's files |
| +// digest is recorded under: its install record (fix round 3). |
| +const filesPlainStep = "files_plain_" |
| + |
| +// filesDigest is the files tree's plain digest, the landing's own measure |
| +// (archive.PlainDigest over the files directory and <host>.env; .files-gen |
| +// is not part of it). The tree is capped at 256 MiB. |
| +func (m *movesProbeOwners) filesDigest(host string) (string, error) { |
| + dir, env := m.FilesDir(host) |
| + plain, _, _, err := archivePlainDigest(dir, env) |
| + if err != nil { |
| + return "", err |
| + } |
| + if plain == "" { |
| + return "", fmt.Errorf("%w: the files tree has no digest", hostbox.ErrChanged) |
| + } |
| + return plain, nil |
| +} |
| + |
| +// recordFilesDigest journals generation g's files digest, the install record |
| +// a later repair compares. A different digest already recorded for g is a |
| +// conflict. |
| +func (m *movesProbeOwners) recordFilesDigest(host, op, g, digest string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + j, err := moves.ReadJournal(m.DataDir, host, op) |
| + if err != nil { |
| + return err |
| + } |
| + if have, ok := j.Steps[filesPlainStep+g]; ok { |
| + if have != digest { |
| + return fmt.Errorf("%w: generation %s recorded other files", hostbox.ErrChanged, g) |
| + } |
| + return nil |
| + } |
| + j.Mark(filesPlainStep+g, digest) |
| + return j.Write(m.DataDir) |
| +} |
| + |
| +// recordedFilesDigest is generation g's recorded files digest: the move id's |
| +// is the source's stopped baseline the landing installed (Move.Source.Files); |
| +// a later generation's is the one its publication journalled. false when |
| +// none is recorded. |
| +func (m *movesProbeOwners) recordedFilesDigest(host string, v probeLanding, g string) (string, bool) { |
| + if g == v.op { |
| + src := v.rec.Move.Source |
| + if src == nil || src.Files == nil || src.Files.Plain == "" { |
| + return "", false |
| + } |
| + return src.Files.Plain, true |
| + } |
| + j, err := moves.ReadJournal(m.DataDir, host, v.op) |
| + if err != nil { |
| + return "", false |
| + } |
| + d, ok := j.Steps[filesPlainStep+g] |
| + return d, ok && d != "" |
| +} |
| + |
| +// verifyFilesContent requires both the landing's placement (identity) and |
| +// the recorded content (the plain digest): never identity alone. |
| +func (m *movesProbeOwners) verifyFilesContent(host string, v probeLanding, want string) error { |
| + if want == "" { |
| + return fmt.Errorf("%w: no recorded files digest", hostbox.ErrChanged) |
| + } |
| + if err := m.verifyFiles(host, v); err != nil { |
| + return err |
| + } |
| + got, err := m.filesDigest(host) |
| + if err != nil { |
| + return err |
| + } |
| + if got != want { |
| + return fmt.Errorf("%w: the files on disk are not the recorded ones", hostbox.ErrChanged) |
| + } |
| + return nil |
| +} |
| + |
| func (m *movesProbeOwners) record(host string, session uint64, receipt ClaimPublicationReceipt) { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| @@ -1302,9 +1393,10 @@ func pinnedLaunchIdentity(in *provision.MoveInputs) hostwrite.Identity { |
| // before the start check, inside the admitted writer's fence: when the |
| // location's published marker names G, G is the admitted generation's cold |
| // predecessor (Task 4's rule), the local .files-gen names an older generation |
| -// of G's own ancestry, and the files on disk are proved the landing's (the |
| -// check publication runs), .files-gen is moved to G. Anything else is left |
| -// alone, for the unchanged start check to decide. |
| +// of G's own ancestry, and the files on disk are proved G's (the landing's |
| +// placement and, fix round 3, the content: the plain digest G's install |
| +// record holds), .files-gen is moved to G. No recorded digest, or anything |
| +// else, is left alone, for the unchanged start check to decide. |
| func (m *movesProbeOwners) repairFilesGen(ctx context.Context, o hostclaim.Observed, v probeLanding, work hostwrite.Work, id hostwrite.Identity) error { |
| h := o.Claim.Holder |
| marker, err := m.readMarker(ctx, h) |
| @@ -1323,13 +1415,17 @@ func (m *movesProbeOwners) repairFilesGen(ctx context.Context, o hostclaim.Obser |
| if err != nil || files == nil || files.Op == g || files.Incarnation != h.Incarnation || !ancestorOf(h, g, files.Op) { |
| return nil |
| } |
| + want, ok := m.recordedFilesDigest(o.Host, v, g) |
| + if !ok { |
| + return nil // nothing recorded for G: the start check decides |
| + } |
| body, err := json.Marshal(moves.FilesGen{Op: g, Incarnation: h.Incarnation}) |
| if err != nil { |
| return err |
| } |
| return work.With(ctx, id, func() error { |
| - if m.verifyFiles(o.Host, v) != nil { |
| - return nil // not the landing's files: the start check decides |
| + if m.verifyFilesContent(o.Host, v, want) != nil { |
| + return nil // not G's files: the start check decides |
| } |
| if err := syncPath(dir); err != nil { |
| return err |